How Vue 3 Reactivity Works Internally
Vue 3 introduced a fully redesigned reactivity system compared to Vue 2, based on Proxy and a dynamic dependency graph. While the public API (ref, reactive, computed, watch) looks simple, the internal behavior follows a model carefully optimized for granularity, performance, and predictability. Understanding this model is not optional in large applications: it is the difference between debugging symptoms and solving root causes.
Introduction Based on a Real Problem
In teams already using Vue 3 in production, the most expensive questions are usually not "how to use ref", but these:
- How does Vue know exactly what to update and what not to touch?
- Why does a valid state change sometimes fail to re-render?
- Why does a screen sometimes re-render too much and become slow?
The answer is not in a single API call. It is in the internal engine: how Vue records dependencies while reading state and how it decides which effects to run when state is written.
When this mental model is unclear, debugging is guesswork. When it is clear, you can reason precisely about root cause, render cost, and the behavior of computed and watchEffect.
Simplified Mental Model
Dependency tracking in one sentence
Every time an active effect (render, computed, watchEffect) reads a reactive property, Vue records a relationship:
efecto X depende de target.keyThen, if target.key changes, Vue only notifies effects subscribed to that key.
Internal structure: WeakMap -> Map -> Set
A simplified model of dependency storage:
WeakMap: index by reactive object (target).Map: index by property inside that object (key).Set: effects that depend on that property.
bucket (WeakMap)
-> targetA (Map)
-> "count" (Set: effectRender, effectComputed)
-> "ok" (Set: effectRender)
-> targetB (Map)
-> "name" (Set: effectProfile)This structure enables:
- Avoiding memory leaks (thanks to
WeakMap). - Property-level granularity.
- Running only the effects that are actually needed.
activeEffect: the critical piece
Vue keeps a temporary global reference to the effect currently executing: activeEffect.
Flow:
effect(fn)starts ->activeEffect = fn.- Inside
fn, a reactive read runstrack(target, key). trackassociatesactiveEffectwith theSetfortarget.key.- When
fnends,activeEffectis restored to the previous one (nested effect stack).
Without activeEffect, there is no tracking.
Mini Implementation from Scratch in Plain JavaScript
The real implementation is more complex (operation types, collection handlers for Map/Set, internal flags, etc.), but this model reproduces the essential pieces:
track(target, key)trigger(target, key)effect(fn)
// Global dependency graph.
const bucket = new WeakMap()
// Active effect and stack for nested effects.
let activeEffect = null
const effectStack = []
function cleanup(effectFn) {
for (const deps of effectFn.deps) {
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
const result = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] ?? null
return result
}
effectFn.deps = []
effectFn.options = options
if (!options.lazy) {
effectFn()
}
return effectFn
}
function track(target, key) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
depsMap = new Map()
bucket.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
if (!deps.has(activeEffect)) {
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
if (!deps) return
const effectsToRun = new Set()
deps.forEach((effectFn) => {
if (effectFn !== activeEffect) effectsToRun.add(effectFn)
})
effectsToRun.forEach((effectFn) => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (!Object.is(oldValue, value)) {
trigger(target, key)
}
return result
}
})
}const state = reactive({ count: 0, ok: true })
effect(() => {
console.log("render ->", state.ok ? state.count : "hidden")
})
state.count++
state.ok = falseStep by step of what happens
effectruns the render function and marks it as active.- Reading
state.okandstate.countmakes the Proxygettrap calltrack. trackstores dependencies inWeakMap -> Map -> Set.- Changing
state.countmakes the Proxysettrap calltrigger. triggerlooks up theSetfor that key and re-runs only dependent effects.
This pattern is exactly the core of Vue 3 reactivity.
Connection to Real Vue
reactive uses Proxy
reactive does not "magically transform" values. It creates a Proxy that intercepts operations (get, set, has, deleteProperty, iteration, etc.).
getperforms tracking.setperforms triggering.- Arrays and collections (
Map,Set,WeakMap,WeakSet) have specialized handlers.
ref wraps primitives
Since a primitive cannot be proxied by property, Vue uses a wrapper object with getter/setter on .value.
Conceptual simplification:
function ref(raw) {
const box = {
get value() {
track(box, "value")
return raw
},
set value(newValue) {
if (!Object.is(raw, newValue)) {
raw = newValue
trigger(box, "value")
}
}
}
return box
}In real Vue:
- Objects passed to
refare internally converted to reactive values. - In templates,
.valueis automatically unwrapped.
computed is built on top of effect
computed is implemented with a lazy effect:
- It is not evaluated until
.valueis read. - It caches the result.
- It gets invalidated when an internal dependency changes.
- It exposes its own
track/triggerfor consumers depending on that computed.
Internally, it combines:
- A lazy effect
- A
dirtyflag - A scheduler that invalidates instead of recalculating immediately
Scheduler and batching
Vue avoids redundant work within the same tick:
- Instead of running every effect immediately, it can enqueue jobs.
- It deduplicates repeated jobs.
- It flushes in a microtask.
Conceptual mini scheduler:
const queue = new Set()
let flushing = false
const p = Promise.resolve()
function scheduler(job) {
queue.add(job)
if (flushing) return
flushing = true
p.then(() => {
queue.forEach((j) => j())
queue.clear()
flushing = false
})
}This is why 10 synchronous mutations can produce one final render.
Common Mistakes
Destructuring breaks reactivity
const state = reactive({ count: 0 })
const { count } = state // count is no longer linkedFix:
- Use
toRef(state, "count") - Or access
state.countdirectly.
Mutations outside the proxy
If you mutate a raw reference outside the Proxy, Vue cannot trigger updates.
Any reactive read/write must go through the proxy or .value.
shallowRef vs ref
shallowRef only tracks replacement of .value, not deep mutations.
const user = shallowRef({ name: "Ana" })
user.value.name = "Eva" // does not trigger by itself
user.value = { ...user.value, name: "Eva" } // does triggerIt is useful when:
- You handle large structures
- You integrate external libraries
- You want explicit control over invalidation timing
Effects running more than expected
Typical causes:
- Reading too many sources inside a single
watchEffect - Returning new objects every time in
computed - Using
watchEffectwherecomputedis enough - Mutating state inside an effect without isolating dependencies
Conclusion
When you understand Vue 3 internals, your UI architecture changes:
- You move from trial-and-error to dependency-driven diagnosis
- You distinguish tracking problems from rendering problems
- You shape state to reduce render frequency and computational cost
In large applications, that directly impacts:
- Stable performance under load
- Fewer intermittent synchronization bugs
- Better decisions around
ref,reactive,shallowRef,computed,watch, and scheduling
Vue 3 reactivity is not magic. It is a well-designed dependency graph plus an efficient scheduler. Understanding it gives you more predictable, more performant, and easier-to-debug code.
