Chapter 16

Component Mount and Update: Complete mountComponent to patch Execution Chain

Chapter 16: Component Mounting and Updating — The Complete mountComponent → patch Execution Chain

Every component update triggered by ref.value = newValue passes through at least 7 function call levels, crossing three subsystems — the reactivity system, the scheduler, and the renderer — yet the entire process completes in under 1 millisecond.

Core question of this chapter: From createApp().mount() to the first DOM node appearing, what steps does Vue go through? When reactive data changes, how is an update efficiently triggered?

After reading this chapter you will understand:

Level 1 · What You Need to Know (1–3 Years Experience)

Vue's Update Mechanism in One Sentence

When you change reactive data, Vue does not immediately update the DOM. It places the update operation into a queue and, after the current JavaScript task ends (microtask timing), executes all queued updates at once.

This is why:

const count = ref(0)
count.value = 1
count.value = 2
count.value = 3
// The DOM updates only once, not three times
// The DOM value you see is 3, not a sequence of 1→2→3

From mount to the First DOM Node: 6 Key Steps

// Your code
const app = createApp(App)
app.mount('#app')

// Key steps Vue executes internally:
// 1. createApp()  → create app instance, configure global plugins/components/directives
// 2. mount()      → find the #app element, create root VNode, call render()
// 3. render()     → call patch(null, rootVNode, container)
// 4. patch()      → identify VNode type, call mountComponent()
// 5. mountComponent() → create component instance, run setup(), create render effect
// 6. setupRenderEffect() → run render() for first time, generate subTree, recursively patch

Why Async Update Queue Matters

const { createApp, ref, nextTick } = Vue

const app = createApp({
  setup() {
    const count = ref(0)

    async function update() {
      count.value = 100

      // DOM has NOT updated yet!
      console.log(document.getElementById('num').textContent) // "0"

      // Wait for DOM update to complete
      await nextTick()

      // DOM is now updated
      console.log(document.getElementById('num').textContent) // "100"
    }

    return { count, update }
  }
})

nextTick() is essentially: run this callback after the current update queue has been flushed. It returns a Promise, so you can await it.

Two Paths That Trigger a Component Update

Path 1: Own reactive data changes

<script setup>
const count = ref(0)

function increment() {
  count.value++  // triggers self-update
}
</script>

Path 2: Parent component passes new props

<!-- Parent -->
<Child :value="parentCount" />

<!-- When parentCount changes, parent updates → patch is performed on the Child VNode
     → if props actually changed, the Child component is notified to update -->

shouldUpdateComponent: Optimizing Child Component Updates

Vue does not blindly update all child components. When a parent updates, Vue compares the new and old VNodes of each child to determine whether the child truly needs to update:

// In parent template:
<Child :name="user.name" :age="user.age" />

// If user.name changed but user.age didn't:
// Vue checks: are the new VNode's props different from the old VNode's props?
// Different → update child component
// Same → skip child update (even though the parent itself updated)

This check is called shouldUpdateComponent and is one of Vue 3's important performance optimizations.


Level 2 · How It Actually Works (3–5 Years Experience)

Complete Execution Chain ASCII Diagram

createApp(App).mount('#app')
│
├─► createApp(App)
│     ├── create app instance (appContext: global config container)
│     ├── register global components/directives/plugins
│     └── return { mount, use, component, directive, ... }
│
└─► mount('#app')
      ├── hostQuerySelector('#app') → find host DOM element
      ├── createVNode(App, {}) → create root component VNode
      └── render(vnode, container)
            │
            └─► patch(null, vnode, container)
                  │ n1=null means first mount
                  │ vnode.shapeFlag & COMPONENT → true
                  │
                  └─► mountComponent(vnode, container)
                        │
                        ├── createComponentInstance(vnode, parent)
                        │     └── create ComponentInternalInstance object
                        │         { uid, type, parent, appContext,
                        │           props, slots, setupContext,
                        │           isMounted: false,
                        │           subTree: null,
                        │           effect: null, ... }
                        │
                        ├── setupComponent(instance)
                        │     ├── initProps()      → process props definitions
                        │     ├── initSlots()      → process slots
                        │     └── setupStatefulComponent()
                        │           ├── set currentInstance = instance
                        │           ├── call setup(props, setupContext)
                        │           │     → user's setup() function runs here
                        │           │     → setup return value is processed
                        │           └── clear currentInstance = null
                        │
                        └── setupRenderEffect(instance, vnode, container)
                              │
                              └── create ReactiveEffect (reactive side effect)
                                    │
                                    First execution (synchronous):
                                    ├── call componentUpdateFn()
                                    │     ├── call render() → generate subTree VNode
                                    │     │   (during render execution, all accessed
                                    │     │    reactive data is auto-registered as deps)
                                    │     ├── patch(null, subTree, container)
                                    │     │   → recursively mount subTree
                                    │     ├── call onMounted hooks
                                    │     └── instance.isMounted = true
                                    │
                                    Subsequent executions (async, scheduled by scheduler):
                                    └── same as above, but patch(prevTree, subTree, ...)
                                        → perform diff + precise update

How setupRenderEffect Connects to Reactivity

// packages/runtime-core/src/renderer.ts (simplified)
const setupRenderEffect = (instance, initialVNode, container, anchor) => {
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      // ─── First mount ───
      const { bm, m } = instance  // beforeMount, mounted hooks

      if (bm) invokeArrayFns(bm)

      // Execute render function, collecting reactive dependencies in the process
      const subTree = (instance.subTree = renderComponentRoot(instance))

      // Recursively mount subTree
      patch(null, subTree, container, anchor, instance)

      initialVNode.el = subTree.el
      instance.isMounted = true

      // Call mounted hooks (executed after nextTick)
      if (m) queuePostFlushCb(m)

    } else {
      // ─── Update ───
      let { next, bu, u, vnode } = instance
      // next is the new VNode from parent (props update scenario)
      if (next) {
        next.el = vnode.el
        updateComponentPreRender(instance, next)
        // updateComponentPreRender updates props and slots
      }

      if (bu) invokeArrayFns(bu)

      const nextTree = renderComponentRoot(instance)
      const prevTree = instance.subTree
      instance.subTree = nextTree

      // diff: compare new and old subTree
      patch(prevTree, nextTree, container, anchor, instance)

      if (u) queuePostFlushCb(u)
    }
  }

  // Create the reactive effect
  // scheduler controls that the effect doesn't re-run immediately but enters the queue
  const effect = (instance.effect = new ReactiveEffect(
    componentUpdateFn,
    EMPTY_FN,
    () => queueJob(instance.update),  // scheduler: push update into queue
    instance.scope
  ))

  const update = (instance.update = effect.run.bind(effect))
  update()  // First execution (synchronous), triggers mount
}

The Complete Async Update Queue

Reactive data change → complete path to triggering an update:

count.value++
    │
    ├── reactive setter is triggered
    │
    ├── triggerEffects(dep)
    │     Iterates all effects subscribed to count
    │     For the component's effect, does NOT call run() directly
    │     Instead calls effect.scheduler()
    │
    └── effect.scheduler = () => queueJob(instance.update)
          │
          └── queueJob(job)
                ├── Check if job is already in queue (deduplication)
                │   Uses job.id for deduplication
                ├── Insert in sorted order by id using binary search
                └── If not currently flushing:
                    flushJobs() is scheduled as a microtask (Promise.resolve().then)

                    Multiple changes in the same tick:
                    count.value = 1 → queueJob (job already in queue, skip)
                    count.value = 2 → queueJob (job already in queue, skip)
                    count.value = 3 → queueJob (job already in queue, skip)

                    Microtask fires:
                    flushJobs() executes all jobs in queue
                    count.value is now 3
                    Only one render is performed

The shouldUpdateComponent Decision Logic

// packages/runtime-core/src/componentRenderUtils.ts (simplified)
export function shouldUpdateComponent(prevVNode, nextVNode, optimized?) {
  const { props: prevProps, children: prevChildren } = prevVNode
  const { props: nextProps, children: nextChildren, patchFlag } = nextVNode

  // Has dynamic slots → must update (slot content may have changed)
  if (patchFlag === PatchFlags.DYNAMIC_SLOTS) return true

  // Same props reference (object unchanged) → no update needed
  if (prevProps === nextProps) return false

  // No new props → check whether old props is empty
  if (!nextProps) return !!prevProps

  // Different number of keys in new vs old props → must update
  const nextKeys = Object.keys(nextProps)
  if (nextKeys.length !== Object.keys(prevProps || {}).length) return true

  // Compare prop values key by key
  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i]
    if (nextProps[key] !== prevProps![key] && !isEmitListener(prevVNode.type.emits, key)) {
      return true  // Found a prop change, update needed
    }
  }

  // Check slot changes
  if (prevChildren || nextChildren) {
    if (!nextChildren || !(nextChildren as any).$stable) {
      return true
    }
  }

  return false  // No props/slots changed, skip update
}

The Dispatch Logic of the patch Function

patch(n1, n2, container, anchor, parentComponent)

n2.type dispatch:
┌──────────────────────────────────────────────────────────────────┐
│ switch(n2.type)                                                   │
│   case Text     → processText()                                   │
│   case Comment  → processCommentNode()                            │
│   case Fragment → processFragment()                               │
│   default:                                                        │
│     if (shapeFlag & ELEMENT)     → processElement()              │
│     if (shapeFlag & COMPONENT)   → processComponent()            │
│     if (shapeFlag & TELEPORT)    → type.process()                │
│     if (shapeFlag & SUSPENSE)    → type.process()                │
└──────────────────────────────────────────────────────────────────┘

processElement(n1, n2, container):
  if (n1 == null) → mountElement()   (first mount)
  else            → patchElement()   (update)

processComponent(n1, n2, container):
  if (n1 == null) → mountComponent()   (first mount)
  else            → updateComponent()  (update)

updateComponent(n1, n2):
  if (shouldUpdateComponent(n1, n2)):
    n2.component = n1.component
    instance.next = n2          ← attach new VNode to instance
    invalidateMount(instance)   ← mark as needing update
    instance.update()           ← trigger re-render
  else:
    // No update needed, just reuse old el
    n2.component = n1.component
    n2.el = n1.el

patchElement: Precise Updates

// packages/runtime-core/src/renderer.ts (simplified)
const patchElement = (n1, n2, ...) => {
  const el = (n2.el = n1.el)  // reuse the real DOM node
  const oldProps = n1.props || {}
  const newProps = n2.props || {}

  // Optimized path: use patchFlag for precise updates
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // Full props diff needed (has dynamic keys)
      patchProps(el, n2, oldProps, newProps, ...)
    } else {
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class)
        }
      }
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style)
      }
      if (patchFlag & PatchFlags.PROPS) {
        // Only compare props listed in dynamicProps
        const propsToUpdate = n2.dynamicProps!
        for (let i = 0; i < propsToUpdate.length; i++) {
          const key = propsToUpdate[i]
          const prev = oldProps[key]
          const next = newProps[key]
          if (next !== prev || key === 'value') {
            hostPatchProp(el, key, prev, next, ...)
          }
        }
      }
    }
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (!optimized) {
    // No patchFlag: full props diff (dynamic render function scenario)
    patchProps(el, n2, oldProps, newProps, ...)
  }

  // Recursively process children
  patchChildren(n1, n2, el, null, ...)
}

Level 3 · Design Docs and Source Code (Senior Developers)

How ReactiveEffect Connects to the Renderer

// packages/reactivity/src/effect.ts (key parts)
export class ReactiveEffect<T = any> {
  active = true
  deps: Dep[] = []         // all collected dependencies (reactive object properties)
  parent: ReactiveEffect | undefined = undefined

  constructor(
    public fn: () => T,          // side effect function (componentUpdateFn)
    public trigger: () => void,  // called when triggered
    public scheduler?: EffectScheduler,  // if present, don't run immediately
    scope?: EffectScope
  ) {
    recordEffectScope(this, scope)
  }

  run() {
    if (!this.active) return this.fn()

    let parent: ReactiveEffect | undefined = activeEffect
    let lastShouldTrack = shouldTrack

    while (parent) {
      if (parent === this) return  // prevent infinite loop
      parent = parent.parent
    }

    try {
      this.parent = activeEffect
      activeEffect = this   // ← set active effect; all reactive accesses register here
      shouldTrack = true
      trackEffects(this.deps)  // prepare to re-collect dependencies
      return this.fn()         // execute side effect (render)
    } finally {
      cleanupEffect(this)      // clean up old dependencies
      activeEffect = this.parent
      this.parent = undefined
      shouldTrack = lastShouldTrack
    }
  }

  stop() {
    if (this.active) {
      cleanupEffect(this)
      this.active = false
    }
  }
}

When the component's render function executes, all reactive/ref accesses trigger track(), registering the current activeEffect (the component's render effect) into their dependency lists. When those values change, trigger() calls effect.scheduler, pushing the update into the queue.

The Complete Component Unmount Chain

// packages/runtime-core/src/renderer.ts (unmountComponent)
const unmountComponent = (instance, doRemove?) => {
  const { bum, scope, update, subTree, um } = instance

  // 1. Call onBeforeUnmount hooks
  if (bum) invokeArrayFns(bum)

  // 2. Stop the reactive effect scope (scope.stop() stops all effects)
  //    This includes the component's render effect and all watch/watchEffect instances
  scope.stop()

  // 3. Stop the render effect
  if (update) {
    update.active = false
    // No more re-renders will be triggered
  }

  // 4. Recursively unmount subTree (triggers children's unmount chains)
  unmount(subTree, instance, ...)

  // 5. Call onUnmounted hooks (after DOM removal)
  if (um) queuePostFlushCb(um)

  // 6. Clear component instance references (aid GC)
  instance.isUnmounted = true
}

flushJobs Sorting Strategy: Parents Update Before Children

// packages/runtime-core/src/scheduler.ts (key logic)
function flushJobs(seen?) {
  // Sort by job.id: parent component's id is smaller than child's (uid increases)
  // This ensures parents update before children
  queue.sort((a, b) => getId(a) - getId(b))

  for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
    const job = queue[flushIndex]
    if (job && job.active !== false) {
      callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      // Note: during job execution, new jobs may be created (child component updates)
      // These are appended to the end of queue and processed in the current loop
    }
  }

  // Flush postFlushCbs (onMounted, onUpdated, etc. run here)
  flushPostFlushCbs(seen)
}

Why parents update first:

queueJob Deduplication Mechanism

// packages/runtime-core/src/scheduler.ts
export function queueJob(job: SchedulerJob) {
  if (
    !queue.length ||
    !queue.includes(
      job,
      isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
    )
  ) {
    if (job.id == null) {
      queue.push(job)
    } else {
      // Insert sorted by id (maintains parent-before-child order)
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()
  }
}

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    // Promise.resolve() implements microtask scheduling
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

queue.includes() is the key to deduplication. The same job (the same component's update function) will only appear in the queue once.


Level 4 · Edge Cases and Traps (Everyone)

Trap 1: Synchronously Modifying Reactive Data in onMounted Causes Double Rendering

// Wrong approach
onMounted(() => {
  count.value = 100  // triggers one update
  title.value = 'Hello'  // triggers another
  // Both modifications call queueJob
  // But onMounted runs inside postFlushCbs
  // At this point flushJobs is already executing
  // These new jobs get appended to the queue tail and run before the current flush ends
  // Result: DOM will update one more time after onMounted finishes
})

// Less bad (same tick, but still an extra render)
onMounted(() => {
  nextTick(() => {
    count.value = 100
  })
})

// Best: initialize correct values with ref in setup, avoid modifying in mounted

Trap 2: watch Flush Timing and DOM State Inconsistency

// watch defaults to flush: 'pre' (runs before component updates)
watch(count, (newVal) => {
  // DOM has NOT updated yet! count has the new value, but DOM is still old
  console.log(document.getElementById('num').textContent) // old value
})

// If you need DOM to be updated, use flush: 'post'
watch(count, (newVal) => {
  // DOM is now updated
  console.log(document.getElementById('num').textContent) // new value
}, { flush: 'post' })

// Or use the shorthand: watchPostEffect
watchPostEffect(() => {
  console.log(document.getElementById('num').textContent)
})

Trap 3: update.active = false Causing a Component to Stop Responding

// Vue's error handling sometimes sets update.active to false
// If the component throws an uncaught exception during render
// the component's effect is stopped and the component enters a "dead" state

// Symptom: reactive data changes but DOM no longer updates
// Diagnosis: check if instance.effect.active is false

// Correctly handling async errors:
const MyComp = defineComponent({
  setup() {
    // Wrong: async exceptions in setup are not caught
    const data = await fetch('/api').then(r => r.json())  // ❌

    // Correct: use ref + onMounted
    const data = ref(null)
    const error = ref(null)
    onMounted(async () => {
      try {
        data.value = await fetch('/api').then(r => r.json())
      } catch(e) {
        error.value = e
      }
    })
    return { data, error }
  }
})

Trap 4: Timing Issue When Modifying Child Props During Component Update

// Scenario: parent modifies data in onUpdated that affects child props

const parentData = ref('initial')

onUpdated(() => {
  // onUpdated runs in postFlushCbs, after children have updated
  // Modifying parentData here triggers another parent update
  // which in turn triggers another child update
  parentData.value = 'changed in onUpdated'  // ⚠️ may cause infinite loop

  // Safe approach: only modify under a specific condition
  if (someCondition && parentData.value !== 'expected') {
    parentData.value = 'expected'
  }
})

Trap 5: Promise Chain Timing of nextTick

// The Promise returned by nextTick resolves after flushJobs completes
// But postFlushCbs (onMounted/onUpdated) inside flushJobs run synchronously
// While nextTick's then callback runs in the next microtask after postFlushCbs

// Timeline:
// 1. Modify reactive data
// 2. queueJob → isFlushPending = true
// 3. resolvedPromise.then(flushJobs) registers a microtask
// 4. Current synchronous code ends
// 5. Microtask: flushJobs() executes (including postFlushCbs)
// 6. nextTick()'s then callback executes (in Promise.resolve().then() inside flushJobs)

async function update() {
  data.value = 'new'
  await nextTick()
  // ← At this point onMounted/onUpdated have finished running and DOM is updated
}

Trap 6: Performance Problems When Many Child Components Update Simultaneously

// Scenario: a parent with 200 child components; a single parent data change
// theoretically triggers 200 shouldUpdateComponent checks

// Vue's shouldUpdateComponent can quickly reject non-updating children in most cases
// but the "check" itself still has a cost

// Optimization 1: use v-memo
// <ChildComp v-memo="[item.id, item.name]" v-for="item in list" />
// Only re-renders when item.id or item.name changes

// Optimization 2: use shallowRef instead of ref to reduce deep tracking
const list = shallowRef([...])

// Optimization 3: for display-only components, freeze props with Object.freeze()
// Frozen objects are not tracked by the reactivity system

Chapter Summary

  1. Mounting is synchronous: The first render triggered by createApp().mount() is synchronous; from mountComponent to the DOM appearing, everything happens within one JS task. The first update() call inside setupRenderEffect is synchronous (does not go through the scheduler).

  2. Updates are async and batched: After a data change, the update job enters queue; via Promise.resolve().then(flushJobs) it executes after the current task ends. No matter how many times a component's data changes in one tick, only one render is performed.

  3. shouldUpdateComponent is the gatekeeper for child updates: When a parent updates, Vue compares new and old VNode props key by key; only when props or slots actually change is a child re-render triggered. This is one of the key reasons Vue 3 is more efficient than Vue 2.

  4. patch is the renderer's dispatch center: It routes work to processElement/processComponent/processFragment based on the VNode's shapeFlag and type. patchElement uses patchFlag to skip comparison of static attributes, enabling precise updates.

  5. Unmounting must stop all side effects: unmountComponent calls scope.stop() to stop all effects within the component's scope (including watch, watchEffect, and the render effect), preventing memory leaks. onBeforeUnmount and onUnmounted are called in sequence during this process.

Rate this chapter
4.5  / 5  (16 ratings)

💬 Comments