effect, track, trigger: The Complete Dependency Tracking Engine of Vue 3
Chapter 5: effect, track, trigger โ The Complete Mechanism of Vue 3's Dependency Tracking Engine
Vue 3's dependency tracking core data structure is
WeakMap<target, Map<key, Set<ReactiveEffect>>>โ three nested levels, each building on the last. The reason it's three levels and not two or four is that the reactivity system must simultaneously satisfy four constraints: precise tracking (who depends on what), automatic cleanup (clean up dependencies when objects are GC'd), deduplication (same effect doesn't subscribe twice), and fast lookup (O(1) dependency queries).
Core Question: How does an assignment to ref.value trigger a template re-render? How many layers of calls are involved?
After reading this chapter, you will understand:
- The complete implementation logic and invocation timing of
effect(),track(), andtrigger() - The mechanism for handling nested effects: why
effectStackorparentpointer is needed - How the scheduler batches synchronous data changes into asynchronous DOM updates
Level 1 ยท What You Need to Know (1-3 Years Experience)
5.1 The Complete Chain from Assignment to DOM Update
When you write count.value++, what happens inside Vue? The complete chain from assignment to DOM update:
Call chain triggered by count.value++:
1. count.value = count.value + 1
โ
2. Proxy set trap is triggered
โ
3. Reflect.set(target, 'value', newValue, receiver)
โ target's value property is actually updated
โ
4. trigger(target, TriggerOpTypes.SET, 'value', newValue, oldValue)
โ finds targetMap.get(target).get('value') โ dep Set
โ
5. Iterates dep Set, schedules execution for each ReactiveEffect
โ If effect has scheduler โ calls scheduler
โ If no scheduler โ directly calls effect.run()
โ
6. Component's render effect has scheduler (queueJob)
โ Adds render task to queue
โ
7. Queue is flushed in nextTick (microtask)
โ
8. Component re-renders: calls render function, generates new VNode tree
โ
9. patch(oldVNode, newVNode): diff and update DOM
The entire chain involves three key functions: track (establishes dependency relationships), trigger (triggers dependency execution), and effect (defines the unit of dependency execution).
5.2 Core Data Structure: Three-Level Nested Map
// Core storage for Vue 3 dependency tracking
const targetMap = new WeakMap();
// targetMap structure:
// WeakMap {
// target1: Map {
// 'key1': Set { effect1, effect2, effect3 },
// 'key2': Set { effect4 }
// },
// target2: Map {
// 'keyA': Set { effect1, effect5 }
// }
// }
Why WeakMap?
WeakMap keys are weak references that don't prevent garbage collection. When a target object has no other references, the GC can collect it, and the corresponding Map (depsMap) in targetMap automatically disappears. This prevents memory leaks: no manual cleanup needed for destroyed components' dependency records.
Why Map for the second level instead of a plain object?
Object keys can only be strings or Symbols, while Map keys can be any value (including Symbols). Reactive property names can be Symbols (like Symbol.iterator), and using Map ensures complete support.
Why Set for the third level instead of an array? Sets automatically deduplicate. If the same effect reads the same property multiple times, it only needs to subscribe once. Using an array would require manual deduplication.
5.3 The effect() Function: Creating Reactive Side Effects
// Simplest usage
import { reactive, effect } from 'vue';
const state = reactive({ count: 0 });
// Create an effect that executes immediately and re-executes when dependencies change
const myEffect = effect(() => {
console.log('count is:', state.count); // Immediately prints: count is: 0
});
state.count = 1; // Auto re-executes: count is: 1
state.count = 2; // Auto re-executes: count is: 2
// effect returns a runner function that can be called manually
myEffect(); // Manual trigger
effect() internally creates a ReactiveEffect instance with several key properties:
fn: the side-effect function passed by the userdeps: all dep Sets this effect depends on (reverse references, for cleanup)scheduler: optional scheduler โ when dependencies change, this runs instead of directly executing fnactive: whether in active state (becomes false afterstop())
5.4 The track() Function: Collecting Dependencies
// track is called from the Proxy's get trap
function track(target, type, key) {
if (shouldTrack && activeEffect) {
// 1. Get/create depsMap for this target
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
// 2. Get/create dep Set for this key
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = createDep()));
}
// 3. Establish bidirectional dependency relationship
trackEffects(dep);
}
}
function trackEffects(dep) {
// Add activeEffect to dep Set (effect subscribes to this dep)
dep.add(activeEffect);
// Add dep to activeEffect.deps (reverse reference: effect records what deps it subscribed to)
activeEffect.deps.push(dep);
}
Key design: bidirectional references
Bidirectional dependency graph:
dep Set โโโโโโโโโโโโโโโโโโ activeEffect.deps
(stores effects subscribed to this dep) (stores deps this effect subscribed to)
โ โ
โผ โผ
[effect1, effect2] [dep1, dep2, dep3]
This bidirectional reference is the foundation of dependency cleanup: when an effect re-executes, it needs to clear old dependency relationships (because if/else branches may change the dependency set), then re-collect. With activeEffect.deps, we can quickly find and remove this effect from all related dep Sets.
5.5 The trigger() Function: Triggering Dependencies
// trigger is called from the Proxy's set/deleteProperty traps
function trigger(target, type, key, newValue, oldValue) {
const depsMap = targetMap.get(target);
if (!depsMap) return; // No effects subscribed to this target, return
let deps = [];
if (type === TriggerOpTypes.CLEAR) {
// Map/Set clear operation: trigger all key dependencies
deps = [...depsMap.values()];
} else if (key === 'length' && isArray(target)) {
// Array length change: trigger length dep and all index deps >= new length
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= newValue) {
deps.push(dep);
}
});
} else {
// Ordinary property operation
if (key !== void 0) {
deps.push(depsMap.get(key)); // Trigger this key's dep
}
// Trigger additional dependencies based on operation type
switch (type) {
case TriggerOpTypes.ADD:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY)); // Object property added, trigger ownKeys dep
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY));
}
} else if (isIntegerKey(key)) {
deps.push(depsMap.get('length')); // Array element added, trigger length dep
}
break;
case TriggerOpTypes.DELETE:
// Delete operation, similar to ADD
break;
case TriggerOpTypes.SET:
if (isMap(target)) {
deps.push(depsMap.get(ITERATE_KEY)); // Map set operation
}
break;
}
}
// Schedule all collected deps uniformly
triggerEffects(createDep(deps.flat()));
}
5.6 activeEffect: How the Global Pointer Works
// Global variable pointing to the currently executing effect
let activeEffect = undefined;
class ReactiveEffect {
run() {
if (!this.active) {
return this.fn(); // Stopped effect executes directly, no tracking
}
try {
// Set current effect as the globally active effect
activeEffect = this;
enableTracking();
// Clean up old dependencies (re-collect)
cleanupEffect(this);
// Execute the user's fn: reactive property accesses inside will trigger track
return this.fn();
} finally {
// Regardless of success or failure, restore previous activeEffect after execution
activeEffect = this.parent; // parent points to the outer effect (nested scenario)
resetTracking();
}
}
}
activeEffect is the central link of the entire dependency tracking system:
- Before executing an effect: set
activeEffect = this - During effect execution: any reactive property's get operation calls
track, andtrackaddsactiveEffectto the dep Set whenactiveEffectis not null - After executing the effect: restore
activeEffect
Level 2 ยท How It Works Under the Hood (3-5 Years Experience)
5.7 Handling Nested Effects
When one effect triggers another:
const outer = effect(() => {
console.log('outer:', state.a);
const inner = effect(() => {
console.log('inner:', state.b);
});
});
If using a single global activeEffect variable with no save/restore mechanism, the inner effect's execution overwrites activeEffect. After the inner effect completes, activeEffect is inner instead of outer:
Wrong behavior (no save/restore):
1. outer starts executing, activeEffect = outer
2. outer reads state.a โ track(state, 'a'), dep.add(outer) โ
3. inner starts executing, activeEffect = inner (overwrites outer!)
4. inner reads state.b โ track(state, 'b'), dep.add(inner) โ
5. inner finishes
6. outer continues executing, but any reactive reads now โ dep.add(inner)! Wrong!
Vue 3 pre-3.2 solution: effectStack
const effectStack: ReactiveEffect[] = [];
class ReactiveEffect {
run() {
if (!effectStack.includes(this)) {
effectStack.push(this); // Push
activeEffect = this;
// ...execute fn...
effectStack.pop(); // Pop
activeEffect = effectStack[effectStack.length - 1]; // Restore to previous
}
}
}
Vue 3.2 optimization: parent pointer
effectStack required includes checks (O(n)) every time. Vue 3.2 optimized this to a parent pointer (O(1)):
class ReactiveEffect {
parent = undefined;
run() {
if (!this.active) return this.fn();
try {
this.parent = activeEffect; // Save parent effect
activeEffect = this;
// ...execute fn...
} finally {
activeEffect = this.parent; // Restore to parent effect
this.parent = undefined;
}
}
}
Parent pointer nested effect diagram:
Currently executing: outer effect
โโโ activeEffect = outer
โโโ outer.parent = undefined (no parent effect)
โ
โโโ inner effect triggered inside
โโโ inner.parent = outer (record parent effect)
โโโ activeEffect = inner
โ
โโโ inner finishes:
โโโ activeEffect = inner.parent = outer (restored)
inner.parent = undefined (cleaned up)
5.8 computed's Lazy Effect and dirty Flag
computed() internally creates a special ReactiveEffect that doesn't execute immediately when dependencies change โ instead it sets a dirty flag and only recomputes the next time it's read:
// Simplified computed implementation (similar to actual source)
class ComputedRefImpl {
_value;
_dirty = true; // Initially dirty, calculates on first read
effect;
constructor(getter) {
// Create an effect with a scheduler
this.effect = new ReactiveEffect(getter, () => {
// scheduler: when dependencies change, don't immediately re-execute โ just mark dirty
if (!this._dirty) {
this._dirty = true;
triggerRefValue(this); // Notify outer effects that depend on this computed
}
});
}
get value() {
trackRefValue(this); // Track dependency on this computed value
if (this._dirty) {
this._dirty = false; // Mark as non-dirty
this._value = this.effect.run(); // Recalculate
}
return this._value;
}
}
computed lazy execution flow:
Initial state: When dependencies change: When read:
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
โ dirty = true โ โ dirty = falseโ โโโโโโโโโโบโ dirty = true โ
โ _value: ??? โ โ _value: old โ scheduler โ run getter โ
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ sets dirtyโ dirty = falseโ
โ _value: new โ
โโโโโโโโโโโโโโโโ
This "compute at the last moment" strategy has important performance implications: if your computed depends on multiple refs that change multiple times in the same tick, computed will only recalculate once when actually read.
5.9 The Scheduler and nextTick
Vue 3's component render effect uses a scheduler that pushes render tasks into a queue rather than executing immediately:
// packages/runtime-core/src/scheduler.ts (simplified)
const queue = [];
let isFlushing = false;
let isFlushPending = false;
// Add task to queue (with deduplication)
export function queueJob(job) {
if (!queue.includes(job, isFlushing && flushIndex)) {
queue.push(job);
queueFlush();
}
}
// Flush queue in the next microtask
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true;
currentFlushPromise = resolvedPromise.then(flushJobs);
// resolvedPromise = Promise.resolve()
// Uses Promise microtask to execute after current synchronous code finishes
}
}
async function flushJobs() {
isFlushPending = false;
isFlushing = true;
// Sort queue (ensures parent components update before children)
queue.sort((a, b) => getId(a) - getId(b));
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex];
if (job.active !== false) {
callWithErrorHandling(job);
}
}
} finally {
flushIndex = 0;
queue.length = 0;
isFlushing = false;
currentFlushPromise = null;
flushPostFlushCbs(); // Handle post-flush callbacks
}
}
Scheduler timing diagram:
Synchronous code
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
count.value = 1 โ trigger โ queueJob(renderEffect) โ isFlushPending=true
count.value = 2 โ trigger โ queueJob(renderEffect, already in queue, skip)
count.value = 3 โ trigger โ queueJob(renderEffect, already in queue, skip)
// Synchronous code finishes
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Microtask queue (Promise.then)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
flushJobs()
โโโ Execute renderEffect (only once! count is already 3)
โโโ Update DOM (only once, directly to final state)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Three assignments trigger only one DOM update โ this is the core of Vue's batch update mechanism.
5.10 Writing a Mini Reactivity System in 100 Lines
// mini-reactivity.js
// Global effect tracking
let activeEffect = null;
const targetMap = new WeakMap();
// track: establish dependency relationship
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
if (!dep.has(activeEffect)) {
dep.add(activeEffect);
activeEffect.deps.push(dep);
}
}
// trigger: fire dependencies
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) {
const effects = [...dep]; // Copy to avoid modifying Set during iteration
effects.forEach(effect => {
if (effect.scheduler) {
effect.scheduler();
} else {
effect.run();
}
});
}
}
// ReactiveEffect class
class ReactiveEffect {
constructor(fn, scheduler = null) {
this.fn = fn;
this.scheduler = scheduler;
this.deps = []; // Reverse references: which deps this effect subscribed to
this.active = true;
this.parent = undefined;
}
run() {
if (!this.active) return this.fn();
const prevEffect = activeEffect;
try {
activeEffect = this;
cleanupEffect(this); // Clear old dependencies
return this.fn();
} finally {
activeEffect = prevEffect;
}
}
stop() {
if (this.active) {
cleanupEffect(this);
this.active = false;
}
}
}
function cleanupEffect(effect) {
const { deps } = effect;
if (deps.length) {
deps.forEach(dep => dep.delete(effect));
deps.length = 0;
}
}
// reactive: create reactive object
function reactive(raw) {
return new Proxy(raw, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
track(target, key);
if (typeof res === 'object' && res !== null) {
return reactive(res); // Lazy proxy (simplified as immediate recursion here)
}
return res;
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (value !== oldValue) {
trigger(target, key);
}
return result;
}
});
}
// ref: wrap primitive types
class RefImpl {
constructor(value) {
this._value = value;
this.dep = new Set(); // Direct Set, no Map level needed
}
get value() {
if (activeEffect) {
this.dep.add(activeEffect);
activeEffect.deps.push(this.dep);
}
return this._value;
}
set value(newValue) {
if (newValue !== this._value) {
this._value = newValue;
const effects = [...this.dep];
effects.forEach(effect => effect.scheduler ? effect.scheduler() : effect.run());
}
}
}
function ref(value) {
return new RefImpl(value);
}
// computed: lazy effect with caching
function computed(getter) {
let _value;
let dirty = true;
const effect = new ReactiveEffect(getter, () => {
// scheduler: when dependencies change, only set dirty โ don't recalculate immediately
dirty = true;
});
return {
get value() {
if (dirty) {
dirty = false;
_value = effect.run();
}
return _value;
}
};
}
// effect: create side effect
function effect(fn, options = {}) {
const _effect = new ReactiveEffect(fn, options.scheduler);
_effect.run(); // Execute immediately once
return _effect.run.bind(_effect); // Return runner
}
// Verification
const state = reactive({ count: 0, name: 'Vue' });
const count = ref(0);
const doubled = computed(() => count.value * 2);
effect(() => {
console.log(`state.count = ${state.count}, doubled = ${doubled.value}`);
});
// Immediately prints: state.count = 0, doubled = 0
state.count++; // Prints: state.count = 1, doubled = 0
count.value = 5; // Prints: state.count = 1, doubled = 10
Level 3 ยท Design Documents and Source Code (Senior Developers)
5.11 Full Source of ReactiveEffect
// packages/reactivity/src/effect.ts (core portions)
export class ReactiveEffect<T = any> {
active = true
deps: Dep[] = [] // Reverse dependency references
parent: ReactiveEffect | undefined = undefined // Parent pointer for nested effects
// Associated computed property (if this is a computed's effect)
computed?: ComputedRefImpl<T>
// Whether to allow recursive self-triggering
allowRecurse?: boolean
// Callback when effect is stopped
onStop?: () => void
// Dev mode only: hooks for tracking start/end
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
scope?: EffectScope | null
) {
recordEffectScope(this, scope) // Register to EffectScope
}
run() {
if (!this.active) {
return this.fn()
}
let parent: ReactiveEffect | undefined = activeEffect
let lastShouldTrack = shouldTrack
// Detect recursion: walk parent chain to see if already executing
while (parent) {
if (parent === this) {
return // Recursion detected, don't execute
}
parent = parent.parent
}
try {
this.parent = activeEffect // Save parent effect
activeEffect = this // Set as current active effect
shouldTrack = true
trackOpBit = 1 << ++effectTrackDepth // Bit mask optimization
if (effectTrackDepth <= maxMarkerBits) {
// Optimized path: use bit markers to track visited deps
initDepMarkers(this)
} else {
// Fallback path: fully cleanup and re-collect
cleanupEffect(this)
}
return this.fn()
} finally {
if (effectTrackDepth <= maxMarkerBits) {
// Clean up deps no longer needed (keep only those accessed this execution)
finalizeDepMarkers(this)
}
trackOpBit = 1 << --effectTrackDepth
activeEffect = this.parent // Restore parent effect
shouldTrack = lastShouldTrack
this.parent = undefined
}
}
stop() {
if (this.active) {
cleanupEffect(this)
if (this.onStop) {
this.onStop()
}
this.active = false
}
}
}
5.12 Bit Mask Optimization for Dependency Cleanup
Vue 3.2 introduced a clever optimization: bit masks to mark which deps were accessed during the current execution, avoiding full cleanup and re-subscription:
// packages/reactivity/src/dep.ts
export const createDep = (effects?: ReactiveEffect[]): Dep => {
const dep = new Set<ReactiveEffect>(effects) as Dep
dep.w = 0 // wasTracked bit flag
dep.n = 0 // newTracked bit flag
return dep
}
// Initialize: mark all existing dependencies as "previously tracked"
function initDepMarkers({ deps }: ReactiveEffect) {
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].w |= trackOpBit // Set wasTracked bit
}
}
}
// Finalize: clean up deps that were "previous" but not "current"
function finalizeDepMarkers(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
let ptr = 0
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]
if (wasTracked(dep) && !newTracked(dep)) {
// This dep was previously tracked but not accessed this execution (dependency removed)
dep.delete(effect)
} else {
deps[ptr++] = dep // Keep still-valid dependencies
}
// Clear bit flags
dep.w &= ~trackOpBit
dep.n &= ~trackOpBit
}
deps.length = ptr // Truncate array, keep only valid ones
}
}
This optimization avoids many unnecessary Set.delete/add operations. In scenarios where an effect's dependency set is mostly stable (no branch changes), performance improves significantly.
5.13 EffectScope: Batch-Managing Effect Lifecycles
Vue 3.2 introduced effectScope() for batch-managing a group of effects:
// packages/reactivity/src/effectScope.ts
export class EffectScope {
active = true
effects: ReactiveEffect[] = [] // All effects belonging to this scope
cleanups: (() => void)[] = [] // Cleanup callbacks
parent: EffectScope | undefined // Parent scope
scopes: EffectScope[] | undefined // Child scopes
run<T>(fn: () => T): T | undefined {
if (this.active) {
const currentEffectScope = activeEffectScope
try {
activeEffectScope = this
return fn() // All effects created inside fn belong to this scope
} finally {
activeEffectScope = currentEffectScope
}
}
}
stop(fromParent?: boolean) {
if (this.active) {
let i, l
for (i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].stop() // Stop all effects
}
for (i = 0, l = this.cleanups.length; i < l; i++) {
this.cleanups[i]() // Execute cleanup callbacks
}
if (this.scopes) {
for (i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].stop(true) // Stop all child scopes
}
}
if (!fromParent && this.parent) {
// Remove self from parent scope's scopes list
const last = this.parent.scopes!.pop()
if (last && last !== this) {
this.parent.scopes![this.index!] = last
}
}
this.active = false
}
}
}
Application in components: Each Vue component instance has a corresponding EffectScope. All watch, watchEffect, and computed calls within the component belong to this scope. When the component unmounts, calling scope.stop() stops all related effects at once, preventing memory leaks.
Level 4 ยท Edge Cases and Traps (Everyone Should Read)
Trap 1: Conditional Branches Change Which Dependencies Are Tracked
import { reactive, effect } from 'vue';
const state = reactive({ count: 0, flag: true });
effect(() => {
if (state.flag) {
console.log('count:', state.count); // count is only tracked when flag=true
}
});
state.flag = false; // effect re-executes, flag=false, count is no longer tracked
state.count = 99; // Does NOT trigger effect! count's dependency was cleaned up
// in the last execution
state.flag = true; // effect re-executes, count is tracked again
state.count = 100; // Triggers effect!
Root cause: Every time an effect executes, Vue first clears all old dependencies, then re-collects. When state.flag = false causes the effect to re-execute, state.count isn't accessed inside the effect (because the if condition is false), so state.count is no longer in the effect's dependency set. This is the correct behavior โ it avoids effect execution when flag=false causes count changes to trigger unnecessarily.
Trap 2: When triggerRef is Necessary
import { ref, triggerRef, watchEffect } from 'vue';
// Scenario: directly mutating the object inside a ref (not via assignment)
const arr = ref([1, 2, 3]);
watchEffect(() => {
console.log('length:', arr.value.length);
});
// Wrong: directly mutating array content doesn't trigger arr's ref update
arr.value.push(4); // watchEffect does NOT re-execute!
// Why? push triggers arr.value (Array Proxy) updates
// But arr (RefImpl)'s setter is NOT triggered
// There's a reactivity propagation issue here
// Correct approach 1: replace the entire array
arr.value = [...arr.value, 4]; // Triggers arr's setter โ triggers ref-tracked effects
// Correct approach 2: manually trigger with triggerRef
arr.value.push(4);
triggerRef(arr); // Manually tells all effects depending on arr to update
Root cause: arr.value returns the Array's Proxy. Operations on it (push, splice, etc.) trigger the array's internal reactivity. But watchEffect tracks arr.value (accessing .value on RefImpl). When the array object referenced by arr.value hasn't changed (only its contents did), the ref's dep is not triggered.
Trap 3: watch's Old Value is undefined on First Execution
import { ref, watch } from 'vue';
const count = ref(0);
watch(count, (newVal, oldVal) => {
console.log(`${oldVal} โ ${newVal}`);
}, { immediate: true });
// Prints: undefined โ 0
// On first execution, oldVal is undefined, not any initial value
// Compare: without immediate
watch(count, (newVal, oldVal) => {
console.log(`${oldVal} โ ${newVal}`);
});
count.value = 1;
// Prints: 0 โ 1 (normal, has old value)
Practical impact: If you access properties on oldVal in a watch callback (oldVal.someProperty), with immediate: true mode, this causes TypeError: Cannot read properties of undefined. Null checking is required: oldVal?.someProperty.
Trap 4: Effect Scheduling Timing and DOM Updates
import { ref, watchEffect, nextTick } from 'vue';
const count = ref(0);
let domValue = '';
// watchEffect by default executes BEFORE DOM updates
watchEffect(() => {
domValue = document.getElementById('counter')?.textContent ?? '';
console.log('DOM content:', domValue, 'count:', count.value);
});
count.value = 1;
// When watchEffect executes, the DOM has NOT been updated yet!
// domValue is the old DOM content, count.value is already 1
// If you need to execute AFTER DOM updates:
watchEffect(() => {
console.log('after DOM update:', document.getElementById('counter')?.textContent);
}, { flush: 'post' }); // Or use watchPostEffect()
// Or manually use nextTick
count.value = 1;
await nextTick();
// DOM has been updated now
console.log(document.getElementById('counter')?.textContent); // Updated value
Timing rules (in execution order):
- Synchronous code (reactive data changes)
preflush effects (default watchEffect, before DOM updates)- DOM updates (component re-render + patch)
postflush effects (watchPostEffect, after DOM updates)nextTickPromise callbacks
Chapter Summary
-
The core dependency tracking data structure is three levels of nesting:
WeakMap<target, Map<key, Set<effect>>>. WeakMap provides automatic GC, Map supports Symbol keys, Set provides deduplication. All three levels are essential โ each has a precise design motivation. -
The
activeEffectglobal pointer is the bridge for dependency collection: before executing an effect, setactiveEffect = this; reactive property accesses inside the effect calltrack, which addsactiveEffectto the dep Set. After execution, restore the previousactiveEffect. Nested effects use parent pointers (Vue 3.2) for correct stack-style management. -
computed is a lazy effect with caching: the
dirtyflag controls whether recalculation is needed. When dependencies change, onlydirty=trueis set without recalculating immediately. Calculation happens when the getter is read. This ensures multiple dependency changes cause only one recalculation (if the computed value is read only once). -
The scheduler is the key to batch updates: the component render effect uses
queueJobas its scheduler, pushing render tasks into the microtask queue. Multiple data changes within one tick for the same component trigger only one re-render โ this is the fundamental reason Vue applications don't collapse under frequent data changes. -
An effect's dependencies are cleaned and re-collected before each execution: this ensures dependency set changes caused by if/else branches are correctly tracked โ reactive properties no longer accessed won't continue triggering the effect, avoiding invalid re-executions.