Appearance
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 await
ed 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,
await
ing 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-levelawait
in atry...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 aisLoading
flag might still be simpler than<Suspense>
.<Suspense>
adds value when coordinating multiple async dependencies.