skip to content

Synchronous Registration Rules

Lifecycle hooks bind to the instance active while setup runs, so they must be registered synchronously and break after an await. Interviewers ask how composables register them safely.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In Vue 3, why must onMounted and other lifecycle hooks be registered synchronously during setup, and what breaks after an await?

level: middleimportance: must knowfreq 52%

answer

  1. which component is this for?
  2. one current instance, set during setup
  3. reset when setup yields
  4. script setup restores top-level awaits

basics

~20 s

Vue 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 lines
ts
import { 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

for a junior

Recall that onMounted and friends must be called during setup, before any await, or they silently do nothing in production.

for a middle

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.

for a senior

Show you audit async setup code for hooks after awaits, including awaits hidden in composables, and restructure to register first and await later.

for a principal

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.
open as a page

In a Vue 3 <script setup> component, what happens when onMounted is registered twice, once directly and once inside a composable?

level: juniorimportance: should knowfreq 42%

basics

~10 s

Vue 3 keeps a list per hook on the component instance, so both onMounted callbacks run, in the order they were registered during setup. A composable's hook attaches to whichever component called the composable.

open as a page

A Vue 3 composable awaits a config fetch and then registers onUnmounted to close a WebSocket, and sockets stay open after navigation; why, and how do you fix it?

level: seniorimportance: should knowfreq 34%

basics

~20 s

The onUnmounted call runs after the composable's own await, when Vue 3 has no current instance, so it is never registered and the socket never closes. Register onUnmounted first, and let it close or cancel whatever exists.

open as a page

In Vue 3, what does getCurrentInstance() return, when does it return null, and why should application code avoid relying on it?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

Vue 3's getCurrentInstance() returns the internal component instance while setup, a render or a lifecycle callback is running, and null elsewhere, including after an await in async setup(). It is an internal-facing escape hatch meant for advanced library code.

open as a page