How reactivity works inside

Vue · Expert · 16 min

Knowing the mechanism explains every reactivity gotcha and is essential when writing libraries or debugging edge cases.

Vue's reactivity is a runtime dependency graph built from Proxies and getter/setter refs.

reactive(obj) returns a Proxy whose get trap calls track (record that the currently running effect depends on target.key) and returns the value, lazily wrapping nested objects in proxies. The set and deleteProperty traps call trigger, which notifies every subscriber of that key. Array methods like push and iteration are instrumented specially (iteration tracks an internal key, length changes trigger it). A ref is an object whose value getter and setter do the same tracking for one value.

An effect is a function run while it is the "active subscriber", so every reactive read it performs subscribes it. Component render functions, computed and watch are all built on this. Dependencies are recollected on every run, which is why branches not taken don't subscribe.

Vue 3.5 rewrote the internals: each dependency keeps a version counter and subscribers are linked through doubly linked lists. A computed is lazy and only recomputes if a dependency's version changed since it last ran, which made computeds cheaper and reduced memory use substantially.

Triggered component updates don't run immediately: they are queued as jobs in a scheduler, deduplicated, sorted parent-first and flushed in a microtask. nextTick() returns that flush's promise. Watchers default to the pre flush (before component updates), post runs after, and sync runs immediately on every change.

Reads outside an effect, through toRaw, or after an await in an effect are not tracked.

Example

// A toy version of Vue's core idea (the real one adds versions, batching and more)
let activeEffect = null
const deps = new WeakMap()   // target -> Map(key -> Set(effect))

function track(target, key) {
  if (!activeEffect) return
  let m = deps.get(target); if (!m) deps.set(target, (m = new Map()))
  let s = m.get(key); if (!s) m.set(key, (s = new Set()))
  s.add(activeEffect)
}
const trigger = (target, key) => deps.get(target)?.get(key)?.forEach((fn) => fn())
function reactive(obj) {
  return new Proxy(obj, {
    get(t, k, r) { track(t, k); return Reflect.get(t, k, r) },
    set(t, k, v, r) { const ok = Reflect.set(t, k, v, r); trigger(t, k); return ok },
  })
}
function effect(fn) {
  const run = () => { activeEffect = run; try { fn() } finally { activeEffect = null } }
  run()
}

const state = reactive({ n: 1 })
effect(() => console.log('n is', state.n))   // n is 1
state.n = 2                                  // n is 2

Remember

  • Proxy get tracks the running effect; set triggers subscribers of that key
  • Renders, computeds and watchers are all effects; dependencies are recollected each run
  • Component updates are batched by a scheduler and flushed in a microtask (nextTick)
  • 3.5 uses per-dependency versions so computeds recompute only when needed

Watch out for

  • Expecting reads after an await inside watchEffect to be tracked; only the synchronous part is.

Check yourself

  1. Inside watchEffect, code reads a.value, then awaits a fetch, then reads b.value. Changing b later...
  2. Why does `state.count++` three times in one handler cause only one re-render?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: vuejs.org/guide/extras/reactivity-in-depth.html