setup() Execution Context: Call Timing, getCurrentInstance and Lifecycle Registration
Chapter 19: The setup() Execution Context — Call Timing, getCurrentInstance, and Lifecycle Registration
onMounted(fn)contains just one meaningful line internally:currentInstance.lifecycle.mounted.push(fn). Lifecycle hooks are not "registered with the Vue framework" — they are pushed into an array on the current component instance. The renderer calls those arrays at the appropriate moments. IfcurrentInstanceis null, your hook will never be called, and no error will be thrown.
Core question of this chapter: Why can't you register lifecycle hooks in setup() after an await? What exactly is getCurrentInstance(), and where does it point?
After reading this chapter you will understand:
- The precise call timing of
setup()in the component mounting chain - The lifecycle and role of the
currentInstanceglobal variable - Why lifecycle hooks must be registered during the synchronous phase of
setup() - The
awaittrap in asyncsetup()and its root cause - The internal relationship between
provide/injectandcurrentInstance - The key fields of the
ComponentInternalInstanceobject
Level 1 · What You Need to Know (1–3 Years Experience)
The Execution Timing of setup(): The Precise Timeline
Component mounting timeline:
createApp(App).mount('#app')
│
├── [sync] createComponentInstance()
│ creates the instance object
│
├── [sync] set currentInstance = instance ← before setup() starts
│
├── [sync] call setup(props, ctx)
│ ← your setup() code runs here
│ ← all lifecycle hooks must be registered here
│
├── [sync] clear currentInstance = null ← after setup() returns
│
├── [sync] process setup()'s return value
│
└── [sync] setupRenderEffect()
├── first render()
├── mount DOM
└── call onMounted hooks (after nextTick)
Note: In Vue 2 terminology:
beforeCreate: approximately after createComponentInstance completes
created: approximately after setup() completes
Options API's beforeCreate/created are also called in this phase
(but setup() runs before beforeCreate, because setup runs before initOptions)
What currentInstance Is
currentInstance is a module-level global variable in the @vue/runtime-core package:
// packages/runtime-core/src/component.ts
export let currentInstance: ComponentInternalInstance | null = null
export const setCurrentInstance = (instance: ComponentInternalInstance) => {
currentInstance = instance
instance.scope.on()
}
export const unsetCurrentInstance = () => {
currentInstance && currentInstance.scope.off()
currentInstance = null
}
How it works:
Time ──────────────────────────────────────────────────────────────►
During Component A's setup() execution
┌──────────────────────────────────────┐
│ currentInstance = instanceA │
│ │
│ onMounted(fn) │
│ ↓ internally: currentInstance.mounted.push(fn) │
│ │
│ provide('key', value) │
│ ↓ internally: currentInstance.provides.key = value │
└──────────────────────────────────────┘
After Component A's setup() returns:
currentInstance = null
During Component B's setup() execution
┌──────────────────────────────────────┐
│ currentInstance = instanceB │
│ ... │
└──────────────────────────────────────┘
Why Lifecycle Hooks Can't Be Registered After await
// The most common trap
const MyComp = defineComponent({
async setup() {
// At this point currentInstance = instance
onMounted(() => console.log('mounted 1')) // ✓ registers successfully
const data = await fetch('/api/data').then(r => r.json())
// After await:
// 1. The current JS task ends
// 2. The caller (setupStatefulComponent) has already executed
// unsetCurrentInstance(), setting currentInstance to null
// 3. Subsequent code resumes in a new microtask
// 4. At this point currentInstance = null
onMounted(() => console.log('mounted 2')) // currentInstance is null — this is a no-op
// No error is thrown! But 'mounted 2' will never be printed
}
})
Root cause:
// packages/runtime-core/src/component.ts (simplified)
function setupStatefulComponent(instance) {
const { setup } = instance.type
setCurrentInstance(instance) // ← set currentInstance
const setupResult = callWithErrorHandling(setup, instance, ...)
unsetCurrentInstance() // ← immediately clear currentInstance
// Note: unsetCurrentInstance is called synchronously
// If setup() returns a Promise (async setup),
// unsetCurrentInstance runs right after the Promise is created,
// NOT after the Promise resolves
if (isPromise(setupResult)) {
// setupResult is a Promise
// But currentInstance has already been cleared!
setupResult.then(result => finishSetup(instance, result))
// Any lifecycle hooks registered inside .then() will fail silently
} else {
finishSetup(instance, setupResult)
}
}
Correct Usage of getCurrentInstance()
import { getCurrentInstance } from 'vue'
// Correct: call during the synchronous phase of setup()
const MyComp = defineComponent({
setup() {
const instance = getCurrentInstance()
console.log(instance?.uid) // the unique ID of the current component
// Common use: getting component context in a custom composable
// Intended for library code; not recommended for business code
return {}
}
})
// Wrong: called outside setup() or after await
const instance = getCurrentInstance() // null
// called in a template event handler: getCurrentInstance() // null
// Wrong: obtaining inside onMounted and storing for use in async callbacks
setup() {
let storedInstance
onMounted(() => {
storedInstance = getCurrentInstance() // valid inside onMounted!
setTimeout(() => {
// storedInstance is not null, but it's a stale reference
// if the component is unmounted, storedInstance still exists — potential memory leak
}, 1000)
})
}
Level 2 · How It Actually Works (3–5 Years Experience)
Key Fields of ComponentInternalInstance
// packages/runtime-core/src/component.ts (key fields)
export interface ComponentInternalInstance {
// Identity fields
uid: number // unique component ID (globally incrementing)
type: ConcreteComponent // component definition object (or functional component function)
parent: ComponentInternalInstance | null // parent component instance
root: ComponentInternalInstance // root component instance
appContext: AppContext // application-level context (global components, directives, provide)
// Data fields
props: ShallowReactiveObject // reactive props (wrapped in shallowReactive)
attrs: Data // attrs not declared in defineProps
slots: InternalSlots // slots
refs: Data // template ref collection
// Execution context
setupContext: SetupContext | null // { attrs, slots, emit, expose }
data: Data // Options API data()
setupState: Data // setup() return object (processed by proxyRefs)
// Rendering
subTree: VNode // currently rendered subTree
effect: ReactiveEffect // render side effect
update: SchedulerJob // update function
render: InternalRenderFunction | null // render function
// Lifecycle hooks (arrays)
bc: LifecycleHook // beforeCreate
c: LifecycleHook // created
bm: LifecycleHook // beforeMount
m: LifecycleHook // mounted
bu: LifecycleHook // beforeUpdate
u: LifecycleHook // updated
bum: LifecycleHook // beforeUnmount
um: LifecycleHook // unmounted
a: LifecycleHook // activated (KeepAlive)
da: LifecycleHook // deactivated (KeepAlive)
ec: LifecycleHook // errorCaptured
// State flags
isMounted: boolean // whether mounted
isUnmounted: boolean // whether unmounted
isDeactivated: boolean // whether deactivated (KeepAlive)
// provide/inject
provides: Data // data provided by this component (inherits from parent via prototype chain)
// Scope
scope: EffectScope // reactive scope (manages all effects)
}
The Lifecycle Hook Registration Mechanism
All onXxx() functions follow the same pattern:
// packages/runtime-core/src/apiLifecycle.ts
export const createHook = <T extends Function = () => any>(
lifecycle: LifecycleHooks
) =>
(hook: T, target: ComponentInternalInstance | null = currentInstance) =>
!isInSSRComponentSetup && injectHook(lifecycle, hook, target)
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)
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] = [])
// Wrap the hook: restore currentInstance when executing
// (allows calling getCurrentInstance() inside the hook)
const wrappedHook =
hook.__weh ||
(hook.__weh = (...args: unknown[]) => {
if (target.isUnmounted) return // component unmounted, skip
pauseTracking() // pause reactive tracking
setCurrentInstance(target) // ← restore currentInstance!
const res = callWithAsyncErrorHandling(hook, target, type, args)
unsetCurrentInstance()
resetTracking()
return res
})
if (prepend) {
hooks.unshift(wrappedHook)
} else {
hooks.push(wrappedHook)
}
return wrappedHook
}
}
Key insight: When a lifecycle hook executes, it restores currentInstance via setCurrentInstance(target). This means: inside an onMounted callback, getCurrentInstance() is valid!
setup() {
onMounted(() => {
// currentInstance is restored when the hook executes, so this works
const instance = getCurrentInstance()
console.log(instance?.uid) // ✓ valid
})
}
Processing setup()'s Return Value
// packages/runtime-core/src/component.ts (simplified)
function finishSetup(instance, setupResult) {
if (isFunction(setupResult)) {
// Returns a function → use as render function
instance.render = setupResult
} else if (isObject(setupResult)) {
// Returns an object → use as setup state (accessible in template and Options API)
// proxyRefs automatically unwraps refs (.count instead of .count.value)
instance.setupState = proxyRefs(setupResult)
}
// Then process Options API (data/computed/methods/watch...)
applyOptions(instance)
}
// Returns an object (most common)
setup() {
const count = ref(0)
return { count }
// In template: {{ count }} (no need for {{ count.value }})
// because proxyRefs automatically unwraps refs
}
// Returns a function (hand-written render function)
setup() {
const count = ref(0)
return () => h('div', count.value) // returned function becomes the render function
}
// The two cannot be mixed: if a function is returned, the template is ignored
provide/inject and Their Relationship with currentInstance
// packages/runtime-core/src/apiInject.ts
export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
if (!currentInstance) {
console.warn(`provide() can only be used inside setup().`)
return
}
let provides = currentInstance.provides
const parentProvides = currentInstance.parent && currentInstance.parent.provides
if (parentProvides === provides) {
// Create own provides object with parent's as prototype
provides = currentInstance.provides = Object.create(parentProvides)
}
provides[key as string] = value
}
export function inject<T>(
key: InjectionKey<T> | string,
defaultValue?: unknown,
treatDefaultAsFactory = false,
): T | undefined {
const instance = currentInstance || currentRenderingInstance
if (instance) {
// Search up the prototype chain, starting from parent's provides
const provides = instance.parent == null
? instance.appContext.provides // root component looks directly in appContext
: instance.parent.provides // others start from parent
if (provides && (key as string | symbol) in provides) {
return provides[key as string]
} else if (arguments.length > 1) {
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(instance.proxy)
: defaultValue
}
}
}
The prototype chain design of provides:
App-level provides (appContext.provides):
{ globalKey: 'globalValue' }
↑ (prototype)
Root component's provides:
{ rootKey: 'rootValue' }
↑ (prototype)
Parent component A's provides:
{ parentKey: 'parentValue' }
↑ (prototype)
Child component B's provides:
{ childKey: 'childValue' }
inject('parentKey') from B:
Start from B's parent (A)'s provides
→ A.provides.parentKey → found 'parentValue' ✓
inject('rootKey') from B:
A.provides.rootKey → undefined (A doesn't have it)
→ follow prototype chain to root → rootKey → found 'rootValue' ✓
inject('globalKey') from B:
→ follow prototype chain to appContext.provides → globalKey → found ✓
Complete Context Management During setup() Execution
Global state changes during setup() execution:
1. setCurrentInstance(instance)
→ currentInstance = instance
→ instance.scope.on() (reactive scope activated)
2. setup(props, ctx) executes
│
├── onMounted(fn)
│ → currentInstance.m.push(wrappedFn)
│
├── ref(0)
│ → creates ref; no tracking needed during setup
│
├── provide('key', value)
│ → currentInstance.provides.key = value
│
└── inject('parentKey')
→ currentInstance.parent.provides.parentKey
3. unsetCurrentInstance()
→ currentInstance = null
→ instance.scope.off() (reactive scope deactivated)
4. Process return value (finishSetup)
5. setupRenderEffect()
→ create ReactiveEffect
→ first render() execution (currentRenderingInstance is set here)
Level 3 · Design Docs and Source Code (Senior Developers)
currentRenderingInstance vs currentInstance
// packages/runtime-core/src/componentRenderContext.ts
export let currentRenderingInstance: ComponentInternalInstance | null = null
// Set during render() function execution
export function setCurrentRenderingInstance(instance: ComponentInternalInstance | null) {
const prev = currentRenderingInstance
currentRenderingInstance = instance
if (instance) {
currentScopeId = instance.type.__scopeId || null
}
return prev
}
Differences between the two:
currentInstance |
currentRenderingInstance |
|
|---|---|---|
| Set | during setup() execution | during render() function execution |
| Used for | lifecycle hook registration, provide/inject | inject (inside render functions), scoped CSS |
| Cleared | after setup() returns | after render() returns |
inject checks both, so it can be used inside render functions (or templates):
// inject's instance source:
const instance = currentInstance || currentRenderingInstance
How the Component Public Proxy Works
The object returned by setup() is wrapped in a Proxy by the renderer, so accessing variables in the template automatically searches the right source:
// packages/runtime-core/src/componentPublicInstance.ts (simplified)
export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
get({ _: instance }: ComponentRenderContext, key: string) {
const { ctx, setupState, data, props, accessCache, type, appContext } = instance
// Priority order:
// 1. setup return value
if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
return setupState[key] // auto-unref (thanks to proxyRefs)
}
// 2. data
if (data !== EMPTY_OBJ && hasOwn(data, key)) {
return data[key]
}
// 3. props
if (props && hasOwn(normalizedProps, key)) {
return props[key]
}
// 4. public properties ($el, $route, $store, etc.)
const publicGetter = publicPropertiesMap[key]
if (publicGetter) {
return publicGetter(instance)
}
// 5. globally registered properties (via app.config.globalProperties)
if (appContext.config.globalProperties[key]) {
return appContext.config.globalProperties[key]
}
}
}
EffectScope and Reactive Management During setup()
// Each component instance has its own EffectScope
// During setup(), scope.on() activates it; all effects created in setup are registered here
// packages/reactivity/src/effectScope.ts
export class EffectScope {
active = true
effects: ReactiveEffect[] = []
cleanups: (() => void)[] = []
parent: EffectScope | undefined
run<T>(fn: () => T): T | undefined {
if (this.active) {
const currentEffectScope = activeEffectScope
try {
activeEffectScope = this
return fn()
} finally {
activeEffectScope = currentEffectScope
}
}
}
stop() {
if (this.active) {
let i, l
for (i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].stop()
}
for (i = 0, l = this.cleanups.length; i < l; i++) {
this.cleanups[i]()
}
this.active = false
}
}
}
All watch/watchEffect/computed created during setup() are registered to the component's scope via activeEffectScope. When the component unmounts, scope.stop() stops all of them at once, preventing memory leaks.
expose(): Controlling External Access
// packages/runtime-core/src/componentOptions.ts
const setupContext: SetupContext = (instance.setupContext = {
attrs,
slots,
emit,
expose: (exposed?: Record<string, any>) => {
instance.exposed = exposed || {}
// If expose({}) is called, the object accessible via template ref
// only contains what's in exposed
// If expose() is never called, all of setupState is accessible externally
}
})
// Child component
const MyInput = defineComponent({
setup(props, { expose }) {
const inputRef = ref(null)
const inputValue = ref('')
// Only expose the focus method to parent
expose({
focus() {
inputRef.value?.focus()
}
})
// inputValue is not exposed; parent cannot access it directly
return { inputRef, inputValue }
}
})
// Parent component
// <MyInput ref="myInput" />
// this.$refs.myInput.focus() → works
// this.$refs.myInput.inputValue → undefined (not exposed)
Level 4 · Edge Cases and Traps (Everyone)
Trap 1: Forgetting to Preserve currentInstance in Composable Functions
// Wrong: if the composable is called in an async context,
// the lifecycle hook registration inside it will fail
// useFetch.js
export function useFetch(url) {
const data = ref(null)
onMounted(async () => { // lifecycle hook registered inside the composable
data.value = await fetch(url).then(r => r.json())
})
return { data }
}
// Component (correct usage)
setup() {
const { data } = useFetch('/api') // ✓ synchronous call, currentInstance is valid
return { data }
}
// Wrong usage
setup() {
async function init() {
const { data } = useFetch('/api') // called inside an async function
// If init is called after an await, currentInstance is already null
}
return {}
}
Trap 2: Providing a ref's .value Instead of the ref Itself
// Wrong: provides a snapshot of the ref's value (not reactive)
const count = ref(0)
provide('count', count.value) // provides the number 0, not a reactive ref
// Correct: provide the entire ref object
provide('count', count) // reactive ref
provide('count', readonly(count)) // read-only ref (safer)
// Or use a computed
provide('doubleCount', computed(() => count.value * 2))
// Child component inject
const count = inject('count') // gets the ref object
// Must use .value when reading
console.log(count.value)
Trap 3: Injecting What You Yourself Provided in the Same Component
// Wrong: a component cannot inject what it itself provided
const MyComp = defineComponent({
setup() {
provide('myKey', 'myValue')
// inject searches from parent.provides, not the component's own provides
const value = inject('myKey')
// value === undefined
return { value }
}
})
Trap 4: getCurrentInstance Return Value May Become Stale After onMounted
// getCurrentInstance returns a reference to the component instance
// If the component unmounts, some properties on the instance are cleared
const instance = getCurrentInstance()
onMounted(() => {
setTimeout(() => {
if (instance.isUnmounted) {
// Component has been unmounted; don't access instance.props etc.
return
}
// Accessing instance here may still cause issues
}, 5000)
})
// Better approach: use a flag variable with onUnmounted
let isActive = true
onUnmounted(() => { isActive = false })
onMounted(() => {
setTimeout(() => {
if (!isActive) return
// Safely execute
}, 5000)
})
Trap 5: EffectScope Makes watch Cleanup Less Obvious
setup() {
const stop = watchEffect(() => {
console.log(count.value)
})
// watchEffect created inside setup() is automatically stopped when the component unmounts
// No need to call stop() manually (though it's harmless to do so)
// Problem: watch created inside an event handler
function onSomeEvent() {
watchEffect(() => { // not inside the component's scope!
console.log(someRef.value)
})
// Even after the component unmounts, this watch remains active — memory leak!
}
// Solution: use onUnmounted or manage manually
function onSomeEvent() {
const stop = watchEffect(() => {
console.log(someRef.value)
})
onUnmounted(stop) // manually stop when component unmounts
}
return {}
}
Trap 6: Behavior Difference Between defineExpose and expose() in Different Usage Modes
// Using <script setup> (most common)
// defineExpose macro: explicitly exposes an interface
// If defineExpose is never called, external ref access gets an empty object {}
// <script setup>
const count = ref(0)
defineExpose({ count })
// Parent ref.value.count is accessible
// Without defineExpose:
// Parent ref.value is {} (empty object)
// Using setup() function (Options API style)
// By default, external ref access can see all of setupState
// After calling expose({...}), only the exposed content is accessible
// The two modes have OPPOSITE defaults!
// <script setup>: defaults to exposing nothing
// setup() function: defaults to exposing all of setupState
Chapter Summary
-
setup() is the nerve center of Vue 3 component mounting: It executes synchronously after
createComponentInstanceand beforesetupRenderEffect. At the start ofsetup(), the global variablecurrentInstanceis set to the current component instance; it is cleared immediately aftersetup()returns. This window is the only valid time for lifecycle hook registration andprovide/injectcalls. -
currentInstance is the implicit parameter of lifecycle hooks: All lifecycle hook APIs like
onMounted(fn)internally depend oncurrentInstanceto know "which component instance to register this hook with." WhencurrentInstanceis null, hook registration silently fails — this is the most common hidden bug in async setup functions. -
The async setup() await trap is a design decision, not a bug:
setupStatefulComponentsynchronously clearscurrentInstanceright after calling setup(). If setup returns a Promise, callbacks inside the Promise execute aftercurrentInstancehas been cleared. To safely use reactive data after async operations, create refs/reactives in the synchronous phase and then modify their.valuein async code. -
provide/inject implements hierarchical lookup via prototype chains: Each component's
providesobject uses the parent'sprovidesas its prototype, forming a prototype chain.injectstarts fromparent.providesand walks up the prototype chain toappContext.provides. A component cannot inject values that it itself provided. -
EffectScope is the lifecycle manager for reactive side effects: All
watch/watchEffect/computedcreated during setup() are registered to the component'sEffectScope. When the component unmounts,scope.stop()stops all of them at once. Effects created outside setup() (in event handlers, global functions) are not in this scope and require manual cleanup.