Skip to content

Top-level await in Vue.js <script setup>

Vue 3's <script setup> offers a streamlined way to write components, and with the advent of top-level await, asynchronous operations become remarkably intuitive. This powerful combination allows you to directly await promises at the root of your script, simplifying data fetching and component initialization.

Top-level await

Traditionally, handling asynchronous operations in Vue components often involved using lifecycle hooks like onMounted or defining separate async functions within the setup option. Top-level await in <script setup> dramatically simplifies this process. It lets you directly await a Promise at the top level of your component script, meaning you can fetch essential asynchronous data or complete initial setups before your component even begins rendering. This is particularly useful for scenarios where a component absolutely requires certain data to display correctly, making your code more linear and easier to reason about.

How it works

When you use await at the top level of a <script setup> block, the component's setup process will pause until the awaited Promise resolves. This mechanism allows you to directly perform initial data fetching or other asynchronous setups before the component starts rendering.

Here's a simple example:

vue
<script setup>
import { ref } from 'vue';

// Simulate fetching a configuration or initial data
const config = await new Promise(resolve => {
  setTimeout(() => {
    resolve({ appName: 'My Awesome App', version: '1.0.0' });
  }, 500); // Wait for 0.5 seconds
});

const message = ref(`Welcome to ${config.appName} (v${config.version})`);
</script>

<template>
  <div>
    <h1>{{ message }}</h1>
  </div>
</template>

In this example, the component's setup waits for the config data to be fetched. This is incredibly useful for situations where your component must have certain data before it can be properly initialized or rendered.

IMPORTANT

While the code above uses top-level await directly in <script setup>, for this component to actually work and render without an error, it must be wrapped by a <Suspense> component in its parent. If you try to render this component directly without a parent <Suspense>, Vue will throw an error because it doesn't know how to handle the asynchronous setup (it won't have a "fallback" to display while waiting).

Important considerations

  • Module Blocking: While powerful, awaiting at the top level means that the entire module's evaluation pauses until the Promise resolves. For simple, quick operations, this is fine. For longer operations, it could potentially block your application's initial render.
  • Error Handling: Just like with any await call, you should wrap top-level await in a try...catch block if you want to handle potential errors gracefully.

<Suspense> basics

<Suspense> is a built-in component in Vue 3 designed to orchestrate asynchronous component loading and provide a seamless loading experience. It works by having two "slots":

  • The #default slot: This is where your actual content goes, including any asynchronous components that might take time to load.
  • The #fallback slot: This content is displayed while any asynchronous components within the #default slot are loading.

When all asynchronous operations within the #default slot are resolved, the #fallback content is replaced with the #default content.

Example

Let's imagine you have an AsyncUserList component that fetches user data:

vue
<script setup>
import { ref } from 'vue';

const users = ref([]);
const loading = ref(true);
const error = ref(null);

try {
  // Simulate an API call
  const response = await new Promise(resolve => setTimeout(() => {
    // resolve({ ok: true, json: () => Promise.resolve([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]) });
    // Or simulate an error for testing:
    resolve({ ok: false, status: 500 });
  }, 1500)); // Simulates a 1.5 second network delay

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  users.value = await response.json();
} catch (e) {
  error.value = e.message;
} finally {
  loading.value = false;
}
</script>

<template>
  <div v-if="error">Error: {{ error }}</div>
  <div v-else-if="loading">Loading user list inside component...</div>
  <ul v-else>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

Now, you can use <Suspense> to manage its loading state in a parent component:

vue
<script setup>
import { defineAsyncComponent } from 'vue';

// Define the AsyncUserList as an async component
const AsyncUserList = defineAsyncComponent(() => import('./AsyncUserList.vue'));
</script>

<template>
  <div>
    <h2>Application Dashboard</h2>
    <Suspense>
      <template #default>
        <AsyncUserList />
      </template>

      <template #fallback>
        <div style="padding: 20px; text-align: center; background-color: #f0f0f0; border-radius: 5px;">
          Loading user data... please wait.
        </div>
      </template>
    </Suspense>
  </div>
</template>

When ParentComponent renders, the AsyncUserList will start fetching its data. While it's doing so, the content of the #fallback slot ("Loading user data...") will be displayed. Once AsyncUserList has finished loading its data, it will replace the fallback content. This provides a single, unified loading experience.

Error handling

When an asynchronous operation within <Suspense>'s #default slot fails, <Suspense> itself does not directly handle the error. Instead, the error propagates up the component tree. If uncaught, this can lead to an unhandled promise rejection and potentially crash your application. The #fallback content won't simply stay visible indefinitely; the underlying operation has failed.

For robust error handling with <Suspense>, you should use an error boundary component. An error boundary is a regular Vue component that utilizes the onErrorCaptured lifecycle hook to "catch" errors from its child components. By wrapping <Suspense> with an error boundary, you can gracefully handle errors, display an appropriate error UI (rather than just the loading state or a crashed app), and ensure a better user experience.

Example of an Error Boundary Component:

vue
<script setup>
import { ref, onErrorCaptured, useSlots } from 'vue';

// Reactive variable to hold the caught error
const error = ref(null);

// Access slots provided to this component
const slots = useSlots();

// Lifecycle hook to capture errors from descendant components
onErrorCaptured((err, vm, info) => {
  error.value = err; // Store the caught error
  console.error("Caught an error:", err, info);
  // Returning false stops the error from propagating further up the component tree
  return false;
});
</script>

<template>
  <div v-if="error">
    <slot name="error" :error="error">
      <p>An error occurred!</p>
    </slot>
  </div>
  <div v-else>
    <slot></slot>
  </div>
</template>

Using the Error Boundary with <Suspense>:

vue
<template>
  <Suspense>
    <ErrorBoundary>
      <template #default>
        <AsyncComponentThatMightFail />
      </template>
      <template #error="{ error }">
        <div style="color: red;">
          <h3>Failed to load!</h3>
          <p>{{ error.message }}</p>
          <button @click="location.reload()">Reload Page</button>
        </div>
      </template>
    </ErrorBoundary>
    <template #fallback>
      <div>Loading content...</div>
    </template>
  </Suspense>
</template>

This setup ensures that if AsyncComponentThatMightFail encounters an error, the ErrorBoundary will catch it and display a user-friendly error message, rather than leaving the user staring at a loading screen.

Important notes and considerations

  • SSR (Server-Side Rendering): <Suspense> is designed to work well with SSR frameworks like Nuxt.js. On the server, <Suspense> will wait for all nested async operations to resolve before rendering the HTML, ensuring the initial server-rendered content is complete. On the client, Vue then performs "hydration" (attaching interactivity) to this static HTML.
  • Experimental Status: While <Suspense> has been part of Vue 3 for a while, it's still officially considered an "experimental" feature. This means its API could potentially undergo minor changes in future Vue versions, though major overhauls are unlikely at this stage. Always check the official Vue documentation for the latest status.
  • When Not to Use Suspense: For very simple asynchronous tasks where you only have one main data fetch, using v-if with a isLoading flag might still be simpler than <Suspense>. <Suspense> adds value when coordinating multiple async dependencies.