Chapter 5

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:


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:

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:


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):

  1. Synchronous code (reactive data changes)
  2. pre flush effects (default watchEffect, before DOM updates)
  3. DOM updates (component re-render + patch)
  4. post flush effects (watchPostEffect, after DOM updates)
  5. nextTick Promise callbacks

Chapter Summary

  1. 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.

  2. The activeEffect global pointer is the bridge for dependency collection: before executing an effect, set activeEffect = this; reactive property accesses inside the effect call track, which adds activeEffect to the dep Set. After execution, restore the previous activeEffect. Nested effects use parent pointers (Vue 3.2) for correct stack-style management.

  3. computed is a lazy effect with caching: the dirty flag controls whether recalculation is needed. When dependencies change, only dirty=true is 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).

  4. The scheduler is the key to batch updates: the component render effect uses queueJob as 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.

  5. 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.

Rate this chapter
4.9  / 5  (67 ratings)

๐Ÿ’ฌ Comments