The Proxy and the Two Maps: Where Dependencies Actually Live
Ask ten Vue developers how reactivity works and nine will say "Proxy". The tenth — the one who gets the follow-up question — will describe the two data structures that the Proxy feeds. Here they are, in the simplified shape they take in Vue's @vue/reactivity package:
// target -> Map(key -> Set<ReactiveEffect>)
const targetMap = new WeakMap()
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
track(target, key) // record: someone is reading key
if (isObject(res)) return reactive(res) // deep, lazily, cached
return res
},
set(target, key, value, receiver) {
const ok = Reflect.set(target, key, value, receiver)
trigger(target, key) // notify: key changed
return ok
}
})
}
The WeakMap is keyed by the target object — and weakly, which is what lets a discarded state object be garbage-collected along with its entry, instead of the reactivity bookkeeping pinning dead state alive forever. Inside it, a Map is keyed by the property name, and each value is a Set of effects — render effects, watchers, computeds — that last read that exact property. That nesting is the source of Vue's property-level precision: state.name = 'x' notifies only the dep set for 'name', never the one for 'state.age'.
track is the read side:
function track(target, key) {
if (!activeEffect) return // plain script reads register NOTHING
const depsMap = targetMap.get(target) ?? set(targetMap, target, new Map())
const dep = depsMap.get(key) ?? set(depsMap, key, new Set())
dep.add(activeEffect) // effect becomes a dependent of this key
activeEffect.deps.push(dep) // reverse edge — for cleanup on stop
}
Line by line, this explains half the pitfalls chapter. The if (!activeEffect) return guard is why console.log(state.x) in a timer registers nothing — during a plain read there is no active effect, so there is nothing to record. The double edge (dep.add(effect) plus effect.deps.push(dep)) exists so that when an effect stops — a component unmounts, a watchEffect's cleanup runs — Vue can walk back from the effect to every dep that holds it and erase it, preventing a stopped render effect from being woken forever.
Nesting deserves one note because it trips people writing their own mini-implementations: effects form a stack, not a single slot. watchEffect(() => { watchEffect(() => state.a); state.b }) — or a render effect whose component mounts another component — saves the previous activeEffect, installs the new one, and restores on exit (try/finally in the real source). Reads register against whichever effect is innermost, and each dep set ends up with exactly the effects that read its key inside their own run.
The other half of the story — what a set does with those dep sets, and how one write becomes one batched render — lives in the trigger and scheduler, next.
Two implementation choices in the sketch also explain behavior you can observe. Child objects are wrapped through a caching reactive() call, so the same nested object read twice yields the same proxy — identity comparisons inside a component tree stay stable, and repeated wrapping never doubles the trap layers. And because dep sets are keyed by property, a hot key like a shared selection flag costs the same to trigger as an obscure one: the dispatch loop runs over readers, not over the object's size, which is why a huge state object with three watched fields remains cheap to update.
Trigger and Scheduler: One Write, Only the Right Effects, Once per Tick
The set trap ends in trigger(target, key), and its job is embarrassingly small once the maps exist — look up the dep set and run its members:
function trigger(target, key) {
const dep = targetMap.get(target)?.get(key)
if (!dep) return // nobody ever read this key: nothing to do
dep.forEach(effect => effect.schedule())
}
Two properties of that loop are the payoff of everything before it. Only previous readers are scheduled. If no effect ever read state.discount, setting it walks an empty (or absent) dep set and returns — no listeners are "notified and ignore it"; the notification is the dep set. And scheduling is not running. An effect's schedule for a render effect does not re-render immediately; it hands itself to the job queue:
const queue = []
let flushed = false
function queueJob(job) {
if (!queue.includes(job)) queue.push(job) // dedupe: same effect queued once
if (!flushed) { flushed = true; Promise.then(flushJobs) }
}
function flushJobs() {
queue.splice(0).forEach(job => job.run()) // synchronous batch, then microtask
flushed = false
}
That is the mechanism behind the batched-updates behavior you observed from the outside: ten count.value++ in one handler enqueue one render effect (the includes dedupe), the flush happens on a microtask after the handler finishes, and the DOM sees one update with final values. It is also why a watcher callback runs after the change and before the next render — both are jobs on the same queue, ordered by creation, executed once.
An effect itself is a small object: the function to run, its deps for cleanup, and a scheduler slot. A render effect's scheduler is queueJob; a watch with a callback gets a scheduler that calls the callback with old/new values; watchEffect re-runs itself eagerly. computed is the variant with a memory: a dirty flag instead of eager scheduling — the setter path marks it dirty without running anything, and the next read re-evaluates lazily. That combination — lazy recomputation on read, re-triggering of downstream readers on change — is why a computed whose inputs never change costs nothing per render, and it is the thread that continues in computed Basics.
One honest complexity note: the real implementation adds layers the sketch omits — dep sets are trimmed so stopped effects leave nothing behind, triggers batch effects by type (avoiding a child rendering before its parent), scheduler jobs carry an id for insertion order, and pauseTracking/resetTracking guards protect internal reads (like iterating a dep set) from polluting the graph. The sketch is the model you should carry; the layers are refinements of it, not different designs.
A worked micro-example cements the cycle. A render effect runs, reads state.query (the read tracks it in query's dep set), then reads state.results.length (registering in that property's dep set). Bumping state.page triggers only the page dep — the render does not re-run and the browser waits. Changing state.query triggers its dep set — the render effect is queued once, and during its next run the old registrations are cleaned up and re-collected against whatever it reads this time. The graph is rebuilt on every run, which is why conditional reads stay correct: a branch that stops touching a key stops being scheduled by it.
Why ref Uses Getters Instead of a Proxy
If the whole system is Proxies, why does ref(0) not proxy the zero? Because there is nothing to proxy: new Proxy(0, ...) is a TypeError — Proxies require an object target. The value must live in an object Vue controls, and the interception point moves from your object's properties to Vue's container's one property: value.
class RefImpl {
constructor(rawValue) {
this._value = convert(rawValue) // objects get wrapped in reactive()
this._rawValue = rawValue
this.dep = new Set() // its own dep set — a ref is one dep
this.__v_isRef = true
}
get value() {
trackRef(this) // activeEffect registers on THIS ref
return this._value
}
set value(newValue) {
if (!Object.is(newValue, this._rawValue)) { // the equality gate
this._rawValue = newValue
this._value = convert(newValue) // fresh objects wrapped on assignment
triggerRef(this) // schedule this ref's dependents
}
}
}
Read it against the pitfalls you have already met, and each one becomes an inevitability rather than a rule to memorize. The .value you must type in script is this class's property — there is no proxy around the ref to unwrap it for you. The Object.is gate in the setter is why a same-value write is a silent no-op. The convert in both constructor and setter is why user.value = freshObject comes out deeply reactive and why a nested mutation after replacement still triggers. And triggerRef fans out through dep — a ref's dependents are computeds and effects that read its value getter, tracked in the same track/trigger machinery, just with the ref itself (not a target object and key) as the address.
Comparing the two halves of the system makes the design legible:
reactive(obj) |
ref(v) |
|
|---|---|---|
| interception point | each property of your object | the container's value accessor |
| works on | objects only | anything |
| address in the dep graph | (target, key) | the ref instance itself |
| deep by default | yes (lazy child proxies) | yes for object values (via reactive) |
| replacement channel | none | the setter |
computed slots into the same picture as a RefImpl subclass with a custom getter: its value access recomputes only when marked dirty, and its own dep set carries the computeds and renders that read it — one more reason the whole system composes: everything reactive is either a proxy feeding targetMap or a container with a dep set, and every effect is just a function with a scheduler.
For interviews, the three-sentence answer to "explain Vue's reactivity" is: reactive wraps objects in a Proxy whose get/set traps record and dispatch through a WeakMap of per-key effect sets; effects — renders, watchers, computeds — register their reads while running and get scheduled (batched, deduped) when a key they read changes; ref extends the same graph to primitives via a container with a tracked getter/setter, because primitives cannot be Proxy targets. Everything else in this topic is evidence that you have actually built that mental model.
One cross-check proves the model end to end: count the moving parts. A proxy handler, two maps, an effect stack, a job queue, and a container class — five pieces — and every debugging rule from the earlier topics is a corollary of one of them. The plain-read guard is the bail-out in track; the silent no-op write is the missing dep in trigger; the .value requirement is the container's accessor; the batching is the queue's dedupe. When the rules feel derivable rather than memorized, the model is in place.
Quiz
1. What data structure maps a reactive object's properties to their dependent effects, and why is the outer container a WeakMap?
2. During a plain script read — `console.log(state.count)` in a setInterval — what does track() do?
3. A property that no effect has ever read is assigned a new value. What happens?
4. Why can't ref reuse the Proxy approach, and what does it use instead?
Flashcards
Question
What are the core data structures of Vue 3's reactivity, and what lives in each?
Click to reveal answer
Answer
targetMap: WeakMap<target, Map<key, Set<ReactiveEffect>>> — per-object, per-key sets of effects that read that key (weak so dead targets collect). Each effect also holds its own deps list for cleanup on stop. A ref is its own 'key': a container with a dep Set, tracked through its value accessor.
Question
Walk the track/trigger cycle.
Click to reveal answer
Answer
Render/watch/computed effect sets activeEffect and runs; every proxy get calls track(target, key), adding the effect to that key's dep set (two-way edge for cleanup). A later proxy set calls trigger(target, key), which schedules exactly the effects in that key's dep set — empty/missing dep means nothing runs. Plain reads (no activeEffect) register nothing.
Question
How does effect scheduling produce batched renders?
Click to reveal answer
Answer
A render effect's scheduler is queueJob: it dedupes (the same effect is queued once per flush) and schedules a microtask flush. Ten writes in one handler therefore produce one re-render with final values; watchers and renders are jobs on the same ordered queue, which is why watcher callbacks run before the next paint.
Question
Why does ref use a getter/setter container instead of a Proxy, and what do its parts do?
Click to reveal answer
Answer
new Proxy requires an object target — primitives can't be proxied. RefImpl stores state in _value with a value accessor: the getter calls track (active effect registers on this ref), the setter gates on Object.is, convert()s object values into reactive proxies, and triggers this ref's dep set. Reactive object values additionally live behind the full Proxy machinery.
Question
How does computed fit into the internals model?
Click to reveal answer
Answer
It is a RefImpl variant with laziness: a dirty flag instead of eager scheduling. Downstream writes mark it dirty without running it; the next .value read recomputes only if dirty. Its dep set carries renders/computeds that read it, so one input change re-triggers readers exactly once per flush — same graph, different scheduling.
Revision Notes
Key Takeaways
- 1. Reactivity is a WeakMap of targets → per-key sets of effects, fed by Proxy get/set traps — property-level precision is the structural default
- 2. track() only records while an effect is running, which is why passive reads register nothing and unread keys are triggered for free
- 3. trigger() dispatches to previous readers only, and scheduling (queueJob dedupe + microtask flush) turns N writes into one render
- 4. ref exists because primitives can't be Proxy targets: a container with a tracked getter/setter, an Object.is gate, and convert() for object values
- 5. computed is the same graph with a dirty flag — laziness, not extra machinery, is what makes derived state cheap
Interview Tips
- • "Explain Vue's reactivity system" — the three-sentence spine (Proxy traps → per-key dep sets → scheduled effects), then offer the ref variant; the follow-up is almost always 'and computed?' (dirty flag, lazy)
- • "Why are console.log reads invisible to reactivity?" — no activeEffect during plain reads, so track bails; connect it to the watchEffect probe technique from the pitfalls topic
- • "What happens when I set a property nothing has read?" — trigger finds no dep set and returns; value still stored — demonstrates the precision model rather than a broadcast model
- • "Why does ref need .value but reactive doesn't?" — primitives aren't Proxy targets, so interception moves to the container's accessor; add the Object.is gate and convert() to show the whole class
Cheat Sheet
Vue 3 Reactivity Internals
The graph:
targetMap: WeakMap< target, Map< key, Set< effect > > >
render effect = { fn, deps, scheduler } ref = container + dep Set + tracked value accessor
The cycle (simplified source shape):
// read
get(target, key) { const v = Reflect.get(...); track(target, key); return isObject(v) ? reactive(v) : v }
function track(t, k) { if (!activeEffect) return; dep(t,k).add(activeEffect); activeEffect.deps.push(dep) }
// write
set(t, k, v) { const ok = Reflect.set(...); trigger(t, k); return ok }
function trigger(t, k) { dep(t,k)?.forEach(e => e.schedule()) } // empty dep → nothing
// ref (primitives can't be Proxy targets)
class RefImpl { get value(){ trackRef(this); return this._value }
set value(v){ if(!Object.is(v,this._raw)){ this._value = convert(v); triggerRef(this) } } }
Key properties: property-level precision (per-key dep sets) · Weak outer map → no leak of dead targets · two-way dep↔effect edges for cleanup on stop · effects nest as a stack (save/restore activeEffect) · scheduling = queueJob with dedupe + microtask flush (batched updates) · computed = ref variant with a dirty flag (lazy recompute).
Consequences you've already met: console.log registers nothing (no activeEffect) · unread keys trigger nothing · same-value write is a no-op (Object.is) · deep lazy child proxies · .value exists because interception had to move to a container.
Interview spine: Proxy traps record reads into per-key effect sets; writes schedule exactly those effects, batched per tick; ref extends the graph to primitives through a tracked accessor.