In Vue 3, why must onMounted and other lifecycle hooks be registered synchronously during setup, and what breaks after an await?
answer
- which component is this for?
- one current instance, set during setup
- reset when setup yields
- script setup restores top-level awaits
basics
~20 sVue 3 attaches each hook to the current component instance, which exists only while setup runs synchronously. After an await in async setup() none is current, so the hook is dropped with a dev warning; script setup restores top-level awaits.
solid answer
~50 s`onMounted(cb)` has no component argument in normal use, so Vue attaches `cb` to the **current instance**: a single internal variable Vue sets just before calling `setup()` and resets as soon as `setup()` returns. An `async setup()` returns at its first `await`, so everything after that runs in a later microtask with no current instance. A hook registered there is dropped, and development builds warn that it is called when there is no active component instance, adding: register lifecycle hooks before the first await statement. Callbacks in `setTimeout` or `.then()` fail the same way. The fix is to register every hook before the first `await`. `<script setup>` is the exception: the compiler wraps each top-level `await` so the instance is restored afterwards, which is why a hook after a top-level await works there — but only at top level, not inside functions it calls.
code
ts · 20 linesimport { defineComponent, onMounted, onUnmounted, ref } from 'vue'
import { loadUser } from './api'
export default defineComponent({
async setup() {
const user = ref<{ name: string } | null>(null)
const onResize = () => console.log(window.innerWidth)
// Registered before the first await: attached to this component
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
user.value = await loadUser()
// After the await: no active instance, dev warning, never runs
onMounted(() => console.log('never logged'))
return { user }
}
})go deeper
Recall that onMounted and friends must be called during setup, before any await, or they silently do nothing in production.
Explain the current-instance variable, why an async setup() loses it at the first await, and why script setup's top-level awaits are the exception.
Show you audit async setup code for hooks after awaits, including awaits hidden in composables, and restructure to register first and await later.
Frame implicit context as a design trade-off: hooks read cleanly without a component argument, at the cost of a timing rule that code review and lint must enforce.
## How a hook finds its component In Vue 3's Composition API you register lifecycle callbacks with functions such as `onMounted`, `onUpdated` and `onUnmounted`. Unlike the Options API, there is no object to attach them to and no `this`. So Vue uses an implicit context: an internal **current instance** variable. 1. Just before calling a component's `setup()` (or the body of `<script setup>`), Vue sets the current instance to that component. 2. Every `onX(cb)` call reads the current instance and appends `cb` to that instance's list for hook `X`. 3. As soon as `setup()` **returns**, Vue resets the current instance. The Vue API reference therefore states that all lifecycle registration APIs must be called **synchronously** during the `setup()` phase. The call may live in another function or a composable — what matters is that it happens on the synchronous call stack started by `setup()`. ## What an await does to that An `async` function **returns a Promise at its first `await`**. From Vue's side, `setup()` has returned, so it resets the current instance. The rest of the function continues later, in a microtask, when this component is no longer current. ```ts async setup() { onMounted(first) // registered: instance is current const user = await loadUser() // setup() returns a Promise here onMounted(second) // no active instance: not registered return { user } } ``` In development Vue prints: `onMounted is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` In production there is no warning; `second` simply never runs. | Where the call happens | Registered? | |---|---| | Directly in `setup()`, before any `await` | Yes | | In a composable called synchronously from `setup()` | Yes | | After an `await` in `async setup()` | **No** — dev warning | | Inside `setTimeout`, `.then()` or an event handler | **No** | | After a **top-level** `await` in `<script setup>` | Yes — context is restored | | After an `await` inside a function called from `<script setup>` | **No** | ## The script setup exception `<script setup>` allows **top-level `await`**; the component is compiled to an `async setup()`. The compiler also rewrites each top-level awaited expression so the current instance is **restored after the await resolves**. That is why the Vue docs say `<script setup>` is the only place where you can call composables after using `await`. The restoration covers top-level awaits of the `<script setup>` body. An `await` inside a function — including an `async` composable's own body — is not rewritten, so code after it runs without an instance. A component using `async setup()` or top-level `await` also has to render inside a `<Suspense>` boundary. ## Fixing registration problems - **Register first, await later.** Put every `onMounted`, `onUnmounted` and composable call above the first `await` in `setup()`. - **Keep async work inside hooks.** Start the fetch in `onMounted` or at setup top level without awaiting it, and assign the result into a `ref` when it arrives. - **Register cleanup before starting the resource.** A hook registered up front can close whatever exists at unmount time. - **Use top-level `await` in `<script setup>`** when the component genuinely needs data before rendering and a `<Suspense>` boundary is in place. ## Diagnosing a lost hook Because production builds drop the registration silently, the symptoms are indirect: - a listener, timer or socket that outlives the component — memory or connection counts that climb with navigation; - an `onMounted` side effect that never happens, such as focus or measurement; - in development, the `no active component instance` warning, which names the hook (`onMounted`, `onUnmounted`, …) that was lost. Search the component and its composables for any `on*` call that appears after an `await`, inside a callback, or inside an `async` helper, and move it to the synchronous part of setup. ## Why interviewers ask The question tests whether you know that Composition API hooks depend on an implicit, synchronously-scoped context rather than on a component reference. Candidates who understand the mechanism can predict every row of the table above — including the surprising ones — instead of memorising a rule.
- Is Vue 3's synchronous-registration rule the same as React's rule that hooks must be called in the same order on every render?No. React calls a component function on every render and matches hook calls by their order, so conditional calls break it. Vue runs `setup()` once per instance; hooks may be registered conditionally or in loops. Vue's only constraint is timing: the call must happen while the instance is current, synchronously during setup.
- Can you register onUnmounted from inside an onMounted callback in Vue 3?Yes. While Vue invokes a lifecycle callback it sets that component as the current instance again, which is why the docs note composables can in some cases be called inside `onMounted()`. A hook registered there attaches to the same component, as long as the call is synchronous within the callback.
- Why does moving the await into a helper function not help?Because the rule is about the call stack, not the file. If the helper awaits and then registers a hook, that registration still runs in a later microtask with no current instance. In `<script setup>` only awaits at the top level of the setup body get the context-restoring rewrite; awaits inside called functions do not.
The current instance is a registration desk that is staffed only while setup runs without pausing. Hand in a form before you step out and it is filed under your component; come back after an await and the desk is closed, so a form handed in then is filed nowhere.
saying these in an interview costs you the question
- Hooks can be registered anywhere in setup, including after an await.
- Vue throws an error when a hook is registered after an await.
- The rule exists because hooks must run in the same order on every render.
- Any await inside a function called from script setup is also context-restored.
- A hook must be written lexically inside setup(), not in a helper.