| |

Vue.js 85 🟢 Deep Reactive Tracking Mechanics (track, trigger, EffectScope)

⏱️ Estimated read: 12 min | |

Vue’s reactivity system is often described as “magic,” but the mechanism is a small set of cooperating functions. A Proxy intercepts property access and calls track to record who is reading. The same Proxy intercepts property writes and calls trigger to notify everyone who was recorded. The EffectScope wraps this machinery in a lifecycle boundary, so that a group of effects can be stopped together rather than individually. Understanding these three pieces explains why a computed recalculates when it should, why a watch in a module scope leaks memory, and why effectScope exists as an advanced API for composable authors.

Key point: track records the currently active effect against the property being read. trigger looks up all effects recorded for that property and schedules them. EffectScope decides which effects are active at the moment track runs—if no scope is active, the effect is not collected and cannot be stopped automatically.


Why these mechanics matter

The dependency graph problem. A reactive object does not know which functions depend on it. The track function builds that knowledge incrementally: every time a property is read inside an active effect, the effect is added to the property’s dependency set . Without track, the system would have no way to know that a computed reading user.name should recalculate when user.name changes.

The notification problem. When a property changes, the system must find every effect that depends on it. The trigger function performs that lookup: it retrieves the dependency set for the property and schedules each effect . The scheduling is batched—multiple changes in the same tick produce a single effect run—which is why Vue updates are asynchronous by default .

The lifecycle problem. An effect created inside a component’s setup is collected by the component’s scope and stopped when the component unmounts. An effect created in a setTimeout callback, a router guard, or a module-level function has no active scope. It is tracked, but it is never stopped . EffectScope exists to give those effects a lifecycle boundary, and onScopeDispose provides a cleanup hook that works in both component and non-component contexts .


a. track and trigger: The Dependency Graph

The track function runs inside the Proxy’s get trap. It checks whether there is an active effect (the activeEffect or activeSub global). If there is, it locates the dependency set for the target object and key, and adds the active effect to that set . The data structure is a WeakMap from target objects to Map instances keyed by property name, and each property maps to a Set of effects . The WeakMap ensures that when a reactive object is garbage collected, its dependency map is also collected.

// Conceptual track implementation
function track(target, key) {
  // No active effect means no dependency to record
  if (!activeEffect) return

  // Get or create the dependency map for this target
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }

  // Get or create the dependency set for this key
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }

  // Record the active effect as a subscriber
  deps.add(activeEffect)
}

The trigger function runs inside the set trap. It retrieves the dependency set for the changed property and schedules each effect for execution . The scheduling is not immediate: effects are queued and flushed in a microtask, which is why a watch callback does not run synchronously after a data change.

// Conceptual trigger implementation
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const effects = depsMap.get(key)
  if (effects) {
    // Schedule each effect for execution
    effects.forEach(effect => scheduleEffect(effect))
  }
}

A subtlety that track must handle is branch switching. If an effect reads obj.ok ? obj.text : 'fallback', and obj.ok becomes false, the effect re-runs and no longer reads obj.text. But the old dependency on obj.text remains in the set unless it is cleaned up. Vue addresses this by removing the effect from all its dependency sets before each re-run, then re-collecting dependencies as the effect executes . This prevents a stale dependency from triggering an unnecessary update when obj.text changes and the effect no longer reads it.


b. EffectScope: Grouping and Disposing Effects

An EffectScope is an object that captures reactive effects created inside its run callback. When scope.stop() is called, every captured effect is stopped. The primary use case is composables and libraries that create effects outside a component’s lifecycle .

import { effectScope, ref, computed, watchEffect } from 'vue'

const scope = effectScope()

const state = scope.run(() => {
  const count = ref(0)
  const double = computed(() => count.value * 2)

  // This watchEffect is captured by the scope
  watchEffect(() => {
    console.log('double is', double.value)
  })

  return { count, double }
})!

// Stopping the scope stops the watchEffect
scope.stop()

The run method returns whatever the callback returns, which allows the scope to encapsulate both the state and the effects that manage it . The detached parameter (effectScope(true)) creates a scope that is not attached to the current parent scope, which is useful for global services that should not be stopped when a consuming component unmounts .

The onScopeDispose function registers a callback that runs when the current scope stops. It is the non-component equivalent of onUnmounted, and it works in both contexts because a component’s setup is itself executed inside an effect scope . A composable that uses onScopeDispose can be used in a component (where the component’s scope triggers the cleanup on unmount) or inside a manual effectScope (where scope.stop() triggers the same cleanup) .

import { onScopeDispose, ref } from 'vue'

export function useWebSocket(url) {
  const messages = ref([])
  const ws = new WebSocket(url)

  ws.onmessage = (e) => messages.value.push(e.data)

  // Runs when the enclosing scope stops
  onScopeDispose(() => {
    ws.close()
  })

  return { messages }
}

c. The Non-Component Leak Problem

The most common reactivity bug is an effect created outside a component’s synchronous setup execution. When watch or watchEffect runs inside setup, the component’s scope is active, and the effect is captured. When the same call runs inside a setTimeout, a promise .then(), or a router guard, no scope is active, and the effect is orphaned . It continues to track dependencies and re-run, but nothing will stop it when the component unmounts.

// LEAK: watchEffect created after await loses the component scope
async function setup() {
  const data = await fetchData()
  // No active scope here; this effect is orphaned
  watchEffect(() => console.log(data.value))
}

The fix is to create the effect synchronously and let it react to the asynchronously loaded data, or to wrap the effect creation in an explicit effectScope and dispose it manually . The <script setup> compiler restores the active instance after top-level await, but nested callbacks (.then(), setTimeout) do not receive this treatment .

// FIX: create the effect synchronously
function setup() {
  const data = ref(null)
  // Effect is created while the scope is active
  watchEffect(() => {
    if (data.value) console.log(data.value)
  })
  fetchData().then(result => { data.value = result })
}

Complete Example Session

// ============================================
// PART 1: THE PROXY AND THE TRAPS
// ============================================
// reactive() creates a Proxy with get and set traps
// get trap: call track(target, key) then return value
// set trap: call trigger(target, key) then set value


// ============================================
// PART 2: TRACK — RECORDING A READ
// ============================================
import { reactive, effect } from 'vue'

const state = reactive({ count: 0 })

effect(() => {
  // Reading state.count triggers the get trap
  // track records this effect against 'count'
  console.log(state.count)
})
// Output: 0


// ============================================
// PART 3: TRIGGER — SCHEDULING A WRITE
// ============================================
state.count = 1
// The set trap calls trigger('count')
// The effect recorded in Part 2 is scheduled
// Output: 1 (in a microtask)


// ============================================
// PART 4: BRANCH SWITCHING CLEANUP
// ============================================
const obj = reactive({ ok: true, text: 'hello' })

effect(() => {
  // When ok is true, reads both ok and text
  // When ok is false, reads only ok
  document.body.innerText = obj.ok ? obj.text : 'not'
})

obj.ok = false
// Effect re-runs, no longer reads text
// Old dependency on text is removed before re-run
obj.text = 'changed'
// Effect does NOT re-run; text is no longer a dependency


// ============================================
// PART 5: EFFECT SCOPE — GROUPING
// ============================================
import { effectScope, ref, watchEffect } from 'vue'

const scope = effectScope()

scope.run(() => {
  const count = ref(0)
  watchEffect(() => console.log(count.value))
  count.value = 1 // Logs 1
})


// ============================================
// PART 6: STOPPING THE SCOPE
// ============================================
scope.stop()
// All effects inside the scope are stopped


// ============================================
// PART 7: ON SCOPE DISPOSE
// ============================================
import { onScopeDispose } from 'vue'

const scope2 = effectScope()
scope2.run(() => {
  onScopeDispose(() => console.log('cleaned up'))
})
scope2.stop()
// Output: cleaned up


// ============================================
// PART 8: DETACHED SCOPE
// ============================================
// Detached scope is not attached to parent scope
const globalScope = effectScope(true)
// Must be stopped manually; does not stop with component


// ============================================
// PART 9: LEAK SCENARIO — EFFECT AFTER AWAIT
// ============================================
// async setup() { await fetch(); watchEffect(...) }
// watchEffect is orphaned; no active scope
// Fix: create effect synchronously, or wrap in effectScope


// ============================================
// PART 10: COMPONENT SCOPE
// ============================================
// A component's setup runs inside its own effect scope
// watchEffect created synchronously in setup is captured
// Component unmount calls scope.stop(), stopping the effect

The ten parts trace the Proxy traps, the dependency recording, the scheduling, the branch cleanup, and the scope lifecycle. Parts 5 and 6 show the scope capturing and stopping effects. Part 9 shows the leak that occurs when an effect is created without an active scope.


Quick Reference

Core Functions

FunctionTriggerPurpose
track(target, key)Proxy getRecord active effect against property
trigger(target, key)Proxy setSchedule effects for property
effectScope()—Create a scope for grouping effects
scope.run(fn)—Execute fn; capture effects created inside
scope.stop()—Stop all captured effects
onScopeDispose(fn)—Register cleanup on scope stop

Data Structure

LayerTypePurpose
Target mapWeakMapObject → deps map
Deps mapMapProperty → dep set
Dep setSetEffects subscribed to property

Scope Behavior

ContextAuto-CapturedAuto-Stopped
Component setup (sync)YesOn unmount
Component setup (after await)NoNo
Router guardNoNo
scope.run()YesOn scope.stop()
Detached scopeYesOn manual scope.stop()

Best Practices

✅ Do This:

// Create effects synchronously in setup
watchEffect(() => { /* ... */ })                                       // ✅
// Use effectScope for composables that create effects outside components
const scope = effectScope(); scope.run(() => { /* ... */ }); scope.stop() // ✅
// Use onScopeDispose for cleanup that works in any scope
onScopeDispose(() => { ws.close() })                                   // ✅

❌ Don’t Do This:

// Create watchEffect after await without an active scope
async function setup() { await fetch(); watchEffect(() => {}) }        // ❌
// Assume detached scope stops with the component
effectScope(true) // Must be stopped manually                              // ❌
// Forget to clean up interval in onScopeDispose
onScopeDispose(() => clearInterval(timer))                             // ✅

Common Pitfalls

PitfallWhy It HappensFix
Effect keeps running after unmountCreated without active scopeCreate synchronously or use effectScope
Stale dependency triggers updateBranch no longer reads propertyVue cleans up on re-run; verify with test
onScopeDispose has no effectNo active scopeEnsure call inside setup or scope.run
Detached scope leakseffectScope(true) not stoppedCall scope.stop() manually
Multiple effects from one composablewatch called multiple timesUse scope.run or manual stop handle

Real-World Examples

1. Track a Property

// Inside Proxy get: track(target, key)

2. Trigger a Property

// Inside Proxy set: trigger(target, key)

3. Branch Cleanup

obj.ok = false // Removes dependency on obj.text

4. Create EffectScope

const scope = effectScope()

5. Run Inside Scope

scope.run(() => { watchEffect(() => {}) })

6. Stop Scope

scope.stop()

7. Register Scope Cleanup

onScopeDispose(() => ws.close())

8. Detached Scope

const globalScope = effectScope(true)

9. Sync Effect Creation

watchEffect(() => {}) // before any await

10. Manual Stop Handle

const stop = watch(source, cb); onScopeDispose(stop)

Visual

The Proxy Interception Cycle

┌──────────────────────────────────────────────────────────────┐
│  REACTIVE OBJECT                                             │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ Proxy           │                                        │
│  │                 │                                        │
│  │ get trap        │────► track(target, key)                │
│  │                 │      Record active effect              │
│  │ set trap        │────► trigger(target, key)              │
│  │                 │      Schedule effects                  │
│  └─────────────────┘                                        │
│                                                              │
│  effect reads state.count                                    │
│         │                                                    │
│         ▼                                                    │
│  get trap → track → add effect to dep set                   │
│                                                              │
│  state.count = 1                                             │
│         │                                                    │
│         ▼                                                    │
│  set trap → trigger → schedule effect from dep set          │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Dependency Data Structure

┌──────────────────────────────────────────────────────────────┐
│  targetMap (WeakMap)                                         │
│  ┌─────────────────────────────────────────┐                │
│  │ key: state object                       │                │
│  │ value: depsMap (Map)                    │                │
│  │   ┌─────────────────────────────────────┐                │
│  │   │ key: 'count'                        │                │
│  │   │ value: depSet (Set)                 │                │
│  │   │   ┌─────────────────────────────┐   │                │
│  │   │   │ effect1                     │   │                │
│  │   │   │ effect2                     │   │                │
│  │   │   └─────────────────────────────┘   │                │
│  │   └─────────────────────────────────────┘                │
│  └─────────────────────────────────────────┘                │
│                                                              │
│  WeakMap: object GC'd → deps map GC'd                        │
│  Set: no duplicate effects per property                      │
│                                                              │
└──────────────────────────────────────────────────────────────┘

EffectScope Lifecycle

┌──────────────────────────────────────────────────────────────┐
│  const scope = effectScope()                                 │
│                                                              │
│  ┌─────────────────────────────────────────┐                │
│  │ scope.run(() => {                       │                │
│  │   watchEffect(...)  ← captured          │                │
│  │   computed(...)     ← captured          │                │
│  │   watch(...)        ← captured          │                │
│  │ })                                      │                │
│  └─────────────────────────────────────────┘                │
│                                                              │
│  scope.stop()                                                │
│         │                                                    │
│         ▼                                                    │
│  ┌─────────────────────────────────────────┐                │
│  │ All captured effects stopped            │                │
│  │ onScopeDispose callbacks run            │                │
│  └─────────────────────────────────────────┘                │
│                                                              │
│  Detached scope (effectScope(true)):                         │
│  Not stopped when parent scope stops.                        │
│  Must be stopped manually.                                   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Leak Scenario

┌──────────────────────────────────────────────────────────────┐
│  COMPONENT SETUP                                             │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ setup() {       │  Component scope active                │
│  │   await fetch() │                                        │
│  │   watchEffect() │  ← No active scope!                   │
│  │ }               │     Effect is orphaned                 │
│  └─────────────────┘                                        │
│                                                              │
│  Component unmounts                                          │
│         │                                                    │
│         ▼                                                    │
│  ┌─────────────────┐                                        │
│  │ scope.stop()    │  Stops effects created in setup        │
│  │                 │  Orphaned effect NOT stopped           │
│  └─────────────────┘                                        │
│                                                              │
│  FIX: create effect before await, or wrap in effectScope    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
trackCalled in Proxy get; records active effect
triggerCalled in Proxy set; schedules effects
Target mapWeakMap of object → deps map
Deps mapMap of property → dep set
Dep setSet of effects
effectScopeGroups effects for collective stop
scope.runCaptures effects created inside
scope.stopStops all captured effects
onScopeDisposeCleanup hook for scope stop
Detached scopeeffectScope(true); manual stop required

Key takeaways:

  • track and trigger are the two halves of the dependency graph. track records the active effect against the property being read. trigger looks up the recorded effects for the property being written and schedules them. The Proxy’s get and set traps are the entry points for these functions .
  • The dependency structure is a three-layer map. A WeakMap from target objects to Map instances keyed by property name, and each property maps to a Set of effects. The WeakMap allows garbage collection of unused reactive objects; the Set prevents duplicate effect subscriptions .
  • Branch switching requires cleanup. When an effect re-runs and no longer reads a property, the old dependency must be removed. Vue removes the effect from all its dependency sets before each re-run and re-collects dependencies during execution .
  • EffectScope provides the lifecycle boundary. A scope captures effects created inside scope.run and stops them all with scope.stop. onScopeDispose registers cleanup that works in any scope, making composables portable between component and non-component contexts .
  • Effects created without an active scope leak. A watchEffect inside setTimeout, a promise .then(), or a router guard has no scope to capture it. It runs forever. The fix is to create effects synchronously or to wrap them in an explicit effectScope .

Remember: Vue’s reactivity is not magic; it is three functions cooperating. The Proxy intercepts reads and calls track to record who is interested. The Proxy intercepts writes and calls trigger to notify those who were recorded. The EffectScope determines which effects are active when track runs, and therefore which effects can be stopped later. When a computed fails to update, the question is whether track recorded the right effect. When an effect leaks, the question is whether an active scope captured it. The mechanics are small, but every reactivity behavior in Vue follows from them.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!