Chapter 19

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. If currentInstance is 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:

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

  1. setup() is the nerve center of Vue 3 component mounting: It executes synchronously after createComponentInstance and before setupRenderEffect. At the start of setup(), the global variable currentInstance is set to the current component instance; it is cleared immediately after setup() returns. This window is the only valid time for lifecycle hook registration and provide/inject calls.

  2. currentInstance is the implicit parameter of lifecycle hooks: All lifecycle hook APIs like onMounted(fn) internally depend on currentInstance to know "which component instance to register this hook with." When currentInstance is null, hook registration silently fails — this is the most common hidden bug in async setup functions.

  3. The async setup() await trap is a design decision, not a bug: setupStatefulComponent synchronously clears currentInstance right after calling setup(). If setup returns a Promise, callbacks inside the Promise execute after currentInstance has been cleared. To safely use reactive data after async operations, create refs/reactives in the synchronous phase and then modify their .value in async code.

  4. provide/inject implements hierarchical lookup via prototype chains: Each component's provides object uses the parent's provides as its prototype, forming a prototype chain. inject starts from parent.provides and walks up the prototype chain to appContext.provides. A component cannot inject values that it itself provided.

  5. EffectScope is the lifecycle manager for reactive side effects: All watch/watchEffect/computed created during setup() are registered to the component's EffectScope. 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.

Rate this chapter
4.6  / 5  (11 ratings)

💬 Comments