Vue.js 85 🟢 Deep Reactive Tracking Mechanics (track, trigger, EffectScope)
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
| Function | Trigger | Purpose |
|---|---|---|
track(target, key) | Proxy get | Record active effect against property |
trigger(target, key) | Proxy set | Schedule 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
| Layer | Type | Purpose |
|---|---|---|
| Target map | WeakMap | Object → deps map |
| Deps map | Map | Property → dep set |
| Dep set | Set | Effects subscribed to property |
Scope Behavior
| Context | Auto-Captured | Auto-Stopped |
|---|---|---|
Component setup (sync) | Yes | On unmount |
Component setup (after await) | No | No |
| Router guard | No | No |
scope.run() | Yes | On scope.stop() |
| Detached scope | Yes | On 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Effect keeps running after unmount | Created without active scope | Create synchronously or use effectScope |
| Stale dependency triggers update | Branch no longer reads property | Vue cleans up on re-run; verify with test |
onScopeDispose has no effect | No active scope | Ensure call inside setup or scope.run |
| Detached scope leaks | effectScope(true) not stopped | Call scope.stop() manually |
| Multiple effects from one composable | watch called multiple times | Use 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
| Item | Value |
|---|---|
track | Called in Proxy get; records active effect |
trigger | Called in Proxy set; schedules effects |
| Target map | WeakMap of object → deps map |
| Deps map | Map of property → dep set |
| Dep set | Set of effects |
effectScope | Groups effects for collective stop |
scope.run | Captures effects created inside |
scope.stop | Stops all captured effects |
onScopeDispose | Cleanup hook for scope stop |
| Detached scope | effectScope(true); manual stop required |
Key takeaways:
trackandtriggerare the two halves of the dependency graph.trackrecords the active effect against the property being read.triggerlooks up the recorded effects for the property being written and schedules them. The Proxy’sgetandsettraps are the entry points for these functions .- The dependency structure is a three-layer map. A
WeakMapfrom target objects toMapinstances keyed by property name, and each property maps to aSetof effects. TheWeakMapallows garbage collection of unused reactive objects; theSetprevents 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 .
EffectScopeprovides the lifecycle boundary. A scope captures effects created insidescope.runand stops them all withscope.stop.onScopeDisposeregisters cleanup that works in any scope, making composables portable between component and non-component contexts .- Effects created without an active scope leak. A
watchEffectinsidesetTimeout, 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 expliciteffectScope.
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.
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!