Why Vue 3 Lifecycle Hooks Fail in Async Setup and How to Fix Them
This article explains the restrictions of using Vue 3 Composition API lifecycle hooks inside asynchronous setup code, demonstrates the warning Vue emits, and shows how the framework links hooks to component instances through its source code, offering a safe way to inject hooks asynchronously.
Preface
When using Vue 3.0 Composition API we usually call lifecycle hook functions such as onMounted and onBeforeDestroy inside the setup phase. Are there any limitations? The answer is yes, as shown by the following examples.
Comparison of injecting lifecycle hooks at different stages
Synchronous stage
This is the usual way of writing lifecycle injection; when the component loads the console prints mounted.
<template>
<div />
</template>
<script lang="ts">
import { onMounted } from 'vue';
export default {
setup() {
onMounted(() => {
console.log('mounted');
});
},
};
</script>Asynchronous stage
If we try to inject a lifecycle hook in an asynchronous stage, Vue prints a warning:
[Vue warn]: 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.The warning means that onMounted is called when there is no active component instance, and lifecycle injection can only happen during the synchronous execution of setup. In an async setup you must register hooks before the first await.
From the source code
2.0 dependency collection and update dispatch
In Vue 2.0 the current component instance ( Watcher) is stored in Dep.target during dependency collection, allowing the framework to associate the component with the variables it depends on. When a variable changes, its setter notifies all watchers in dep.subs.
We suspect Vue 3 uses a similar idea to link lifecycle hooks with the component instance.
Definition of lifecycle hooks in 3.0
In packages/runtime-core/src/apiLifecycle.ts the hooks are defined as follows:
export const onBeforeMount = createHook(LifecycleHooks.BEFORE_MOUNT)
export const onMounted = createHook(LifecycleHooks.MOUNTED)
export const onBeforeUpdate = createHook(LifecycleHooks.BEFORE_UPDATE)
export const onUpdated = createHook(LifecycleHooks.UPDATED)
export const onBeforeUnmount = createHook(LifecycleHooks.BEFORE_UNMOUNT)
export const onUnmounted = createHook(LifecycleHooks.UNMOUNTED)
export const onServerPrefetch = createHook(LifecycleHooks.SERVER_PREFETCH)
export const onRenderTriggered = createHook<DebuggerHook>(LifecycleHooks.RENDER_TRIGGERED)
export const onRenderTracked = createHook<DebuggerHook>(LifecycleHooks.RENDER_TRACKED)Each hook is created by createHook, which ultimately calls injectHook to register the hook with a target instance.
export const createHook =
<T extends Function = () => any>(lifecycle: LifecycleHooks) =>
(hook: T, target: ComponentInternalInstance | null = currentInstance) =>
(!isInSSRComponentSetup || lifecycle === LifecycleHooks.SERVER_PREFETCH) &&
injectHook(lifecycle, hook, target)The injectHook function injects the callback into the target's lifecycle queue:
export function injectHook(
type: LifecycleHooks,
hook: Function & { __weh?: Function },
target: ComponentInternalInstance | null = currentInstance,
prepend: boolean = false
): Function | undefined {
if (target) {
const hooks = target[type] || (target[type] = [])
const wrappedHook = /* omitted */ (...args: unknown[]) => {
const res = callWithAsyncErrorHandling(hook, target, type, args)
return res
}
if (prepend) {
hooks.unshift(wrappedHook)
} else {
hooks.push(wrappedHook)
}
return wrappedHook
} else if (__DEV__) {
// omitted
}
}What is target ?
targetis the component instance that is currently being created, represented by currentInstance.
export let currentInstance: ComponentInternalInstance | null = null
export const getCurrentInstance = () => currentInstance || currentRenderingInstance
export const setCurrentInstance = (instance: ComponentInternalInstance) => {
currentInstance = instance
instance.scope.on()
}
export const unsetCurrentInstance = () => {
currentInstance && currentInstance.scope.off()
currentInstance = null
}During setupStatefulComponent the framework sets the current instance before calling setup and clears it afterwards:
setCurrentInstance(instance)
pauseTracking()
const setupResult = callWithErrorHandling(
setup,
instance,
ErrorCodes.SETUP_FUNCTION,
[__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
)
resetTracking()
unsetCurrentInstance()mountComponent
The component instance is created and setupComponent is invoked:
const instance: ComponentInternalInstance =
compatMountInstance ||
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense
))
setupComponent(instance)More thoughts
Why lifecycle hooks cannot be called in async stage
Because setup is non‑blocking; after the synchronous part finishes Vue immediately clears the current instance, so any later hook registration has no active component to attach to, which triggers the warning.
setCurrentInstance(instance)
// ...setup logic...
unsetCurrentInstance()How to inject a lifecycle hook asynchronously
Vue recommends injecting hooks within the synchronous setup period, but createHook also accepts an explicit instance, allowing manual async injection.
Example:
<!-- parent.vue -->
<template>
<div>
<async-lifecycle v-if="isShow" />
<button @click="hide">hide</button>
</div>
</template>
<script lang="ts">
import { ref } from 'vue'
import AsyncLifecycle from './async-lifecycle.vue'
export default {
components: { AsyncLifecycle },
setup() {
const isShow = ref(true)
const hide = () => { isShow.value = false }
return { isShow, hide }
}
}
</script> <!-- async-lifecycle.vue -->
<template>
<div />
</template>
<script lang="ts">
import { getCurrentInstance, onUnmounted } from 'vue'
export default {
setup() {
const instance = getCurrentInstance()
setTimeout(() => {
// async injection of unmount hook
onUnmounted(() => {
console.log('unmounted')
}, instance)
})
}
}
</script>When the parent button hides the child component, the console logs unmounted, confirming the approach works.
Conclusion
By examining Vue's source we learned how Composition API lifecycle hooks are linked to component instances, why asynchronous injection triggers warnings, and how to safely inject hooks asynchronously by passing the component instance explicitly. Understanding these internals helps write correct Composition API code and debug related issues.
For a lightweight study of Vue 3 internals, the mini‑vue project is recommended.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
BaiPing Technology
Official account of the BaiPing app technology team. Dedicated to enhancing human productivity through technology. | DRINK FOR FUN!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
