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 = newValuepasses 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:
- The complete call chain from
createAppto the first render - How
setupRenderEffectconnects the reactivity system and the renderer - The two triggering paths for component updates and their optimization strategies
- How the async update queue works (why multiple changes in the same frame result in only one render)
- The dispatch logic of
patch()and the precise update mechanism ofpatchElement
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:
- Parent component uids are generated before child uids (numerically smaller)
shouldUpdateComponentis evaluated during the parent's update phase to decide whether the child needs to update- If the child's props haven't changed after the parent updates, the child's update job can be skipped (by setting
job.active = false)
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
-
Mounting is synchronous: The first render triggered by
createApp().mount()is synchronous; frommountComponentto the DOM appearing, everything happens within one JS task. The firstupdate()call insidesetupRenderEffectis synchronous (does not go through the scheduler). -
Updates are async and batched: After a data change, the update job enters
queue; viaPromise.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. -
shouldUpdateComponentis 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. -
patchis the renderer's dispatch center: It routes work toprocessElement/processComponent/processFragmentbased on the VNode'sshapeFlagandtype.patchElementusespatchFlagto skip comparison of static attributes, enabling precise updates. -
Unmounting must stop all side effects:
unmountComponentcallsscope.stop()to stop all effects within the component's scope (includingwatch,watchEffect, and the render effect), preventing memory leaks.onBeforeUnmountandonUnmountedare called in sequence during this process.