What changes when a Vue 3 component's setup() is async, and why do lifecycle hooks registered after its first await fail?
answer
- setup now returns a promise
- who waits for that promise?
- current instance cleared at the await
- register hooks before the first await
- script setup rewrites each await
basics
~20 sAn async setup() returns a promise, so the component renders only under a Suspense ancestor; and because Vue's current instance is cleared at the first await, hooks, watchers and inject() calls after it are not bound to the component.
solid answer
~40 sAn `async setup()` returns a promise instead of bindings. Vue can only wait for that promise when the component has a `<Suspense>` ancestor; without one it is not rendered and development builds warn that a Suspense boundary is required. The second change is subtler. Lifecycle APIs attach to Vue's *current instance*, which is set while `setup()` runs synchronously and cleared when it returns, and an async function returns at its first `await`. So `onMounted()` after the await finds no instance: the dev warning tells you to register lifecycle hooks before the first await, and watchers created there are never stopped automatically. `<script setup>` avoids this: the SFC compiler wraps each top-level `await` so the instance is restored afterwards.
code
ts · 14 linesimport { defineComponent, onMounted, ref } from 'vue'
export default defineComponent({
async setup() {
const ready = ref(false)
onMounted(() => { ready.value = true }) // before the first await: bound to this instance
const post = await fetch('/api/post/1').then((r) => r.json())
// onMounted(() => { ... }) // after the await: no current instance, dev warning
return { post, ready }
},
})go deeper
Recall that an async setup() needs a Suspense ancestor to render, and that hooks go before the first await.
Explain the current-instance mechanism: set during the synchronous call, cleared when an async setup returns at its first await.
Diagnose missing hooks and leaking watchers in async setup, contrast the script setup compiler rewrite, and choose non-blocking loading where a fallback is unwanted.
Decide where async setup and Suspense belong in an app's loading strategy, weighing blocked subtrees against simpler per-component loading states.
## Two consequences of returning a promise A `setup()` declared `async`, or `<script setup>` that uses top-level `await`, returns a **promise** instead of an object of bindings or a render function. That changes two things: 1. **Who waits for the result.** The component cannot render until the promise resolves, so something must coordinate that wait. 2. **What counts as "during setup".** Vue's composition APIs rely on knowing which component is being set up, and an async function stops being "during setup" at its first `await`. ## Who waits: Suspense On the client, Vue marks an async setup as a dependency that a `<Suspense>` boundary must wait for. If none exists, the component is not rendered, and development builds warn: > Component <Name>: setup function returned a promise, but no <Suspense> boundary was found in the parent component tree. A component with async setup() must be nested in a <Suspense> in order to be rendered. During server-side rendering, the server renderer awaits the promise before producing the component's HTML. The docs still describe `<Suspense>` as experimental; its fallback content and events are a separate subject. ## Why hooks after the first await fail Lifecycle functions such as `onMounted()` and `onUnmounted()`, `inject()`, and the automatic ownership of watchers all depend on an internal **current instance**. Vue sets it immediately before calling `setup()` and resets it as soon as the call returns. An async function returns, with a pending promise, when it reaches its first `await`. Everything after that line runs later, in a microtask, when the current instance is no longer set. | Code position in a hand-written `async setup()` | Current instance | Result | |---|---|---| | before the first `await` | set | hooks registered, watchers owned | | after the first `await` | not set | hooks dropped with a dev warning, watchers leak, `inject()` warns | The warning a late hook produces ends with the advice itself: `If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` In production the hook is silently not registered. ## How <script setup> handles the same code Top-level `await` in `<script setup>` compiles to an `async setup()` too, but the SFC compiler rewrites each awaited expression through an internal helper that clears the instance while waiting and **restores it when execution resumes**. So this works in `<script setup>`: ```vue <script setup lang="ts"> import { onMounted } from 'vue' const post = await fetch('/api/post/1').then((r) => r.json()) onMounted(() => console.log('mounted with', post.title)) // still bound to this component </script> ``` The identical code in a hand-written `async setup()` registers nothing. Awaits directly in the `<script setup>` body, including inside its `if` and `for` blocks, get this treatment; an `await` inside a nested function or callback does not. ## Practical rules - **Register first, await later.** In a hand-written `async setup()`, call `onMounted()`, `watch()`, `inject()` and composables before the first `await`. - **Keep awaited work small.** Everything above the component waits for its Suspense boundary to resolve, so a slow request delays that whole subtree. - **Prefer non-blocking loading when a fallback UI is not wanted.** A synchronous setup that starts the request and shows its own loading state avoids the Suspense requirement altogether. - **Handle rejection deliberately.** A rejected setup promise is a component error; Vue's error handling applies, and the docs note that an async component will still attempt to render its template after a failed `setup()`. ## Checklist for review - Is there a `<Suspense>` above every component whose setup is async? - In hand-written `async setup()`, does anything that needs the instance appear after an `await`? - Could the component load data without blocking render, and would that be simpler?
- Why does the same onMounted() call work after an await in <script setup> but not in a hand-written async setup()?The SFC compiler rewrites each top-level `await` in `<script setup>` through a helper that unsets the current instance while waiting and restores it when execution resumes. A hand-written `async setup()` gets no such rewrite, so code after its first `await` runs with no current instance.
- What happens to a watcher created after the first await in a hand-written async setup()?It is created with no owning component, so it is not stopped when the component unmounts. It keeps running and holding references until you call the stop handle `watch()` returned. Move it above the first `await`, or keep and call that handle.
saying these in an interview costs you the question
- An async setup() component renders fine anywhere; it simply shows nothing until data arrives.
- Vue restores the component instance after every await in any setup() function.
- onMounted() registered after an await in async setup() is queued and runs later.
- Top-level await in script setup needs no Suspense ancestor.
- A late hook fails loudly in production, so this bug cannot ship unnoticed.