Vue Reactivity Internals — Proxy, track/trigger, Effects
TL;DR
Vue 3’s reactivity is built on Proxy: when you read a property of a reactive object, Vue records “which effect (computed, render function, watcher) read me” — that’s tracking. When you mutate it, Vue runs all the effects that read it — that’s triggering. The whole API (ref, reactive, computed, watch, watchEffect, even template re-renders) is built on these two primitives. Understanding this kills 90% of reactivity bugs.
In depth
How does reactive() actually work?
It wraps the object in a Proxy with get and set handlers:
// Conceptual implementation (Vue's is more complex but this is the core)
const targetMap = new WeakMap<object, Map<PropertyKey, Set<Effect>>>();
let activeEffect: Effect | null = null;
function track(target: object, key: PropertyKey) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
dep.add(activeEffect);
}
function trigger(target: object, key: PropertyKey) {
const dep = targetMap.get(target)?.get(key);
dep?.forEach(effect => effect.run());
}
function reactive<T extends object>(target: T): T {
return new Proxy(target, {
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; },
});
}Every property read during an effect’s execution is tracked. Every write triggers re-runs of dependent effects. The render function is just one such effect.
What’s an “effect” in Vue’s reactivity system?
Any function whose dependencies should be re-collected each run. Three flavors of effect ship in the public API:
| Effect | What it does |
|---|---|
| Render effect | re-renders the template when its reactive deps change |
computed |
re-computes its value when its deps change (lazy) |
watchEffect / watch |
runs a side effect when its deps change |
All three call into the same effect() internal primitive — a function that:
- Sets itself as the
activeEffect. - Runs the user code (which triggers
gettraps → callstrack). - Unsets
activeEffect. - Stores the dep set so it can be cleaned up next run.
Why Proxy and not Object.defineProperty (the Vue 2 way)?
defineProperty per-property has fatal gaps:
- No detection of property addition/removal — Vue 2 needed
Vue.set/Vue.delete. - No detection of array index assignment (
arr[0] = x) without manual wrapping. - No detection of
Map/Setmutations. - One getter/setter per property = expensive on large objects.
Proxy traps all operations on the object — add, delete, has, keys, indexed access. Vue 3 supports Map, Set, WeakMap, WeakSet as reactive. Cost: Proxy is IE-incompatible (the trade-off Vue 3 chose).
What gets tracked, and what doesn’t?
Tracked:
- Property reads on a
reactiveobject:state.count .valuereads on aref:count.value- Iteration:
for (const item of arr) has:"foo" in statekeys/values/entriesMap/Setreads (map.get,set.has, iteration)
Not tracked:
- Reads on the raw (unwrapped) object —
toRaw(state).count - Reads inside a
watchsource function that’s() => state(the whole object, not a deep traversal — Vue’s still smart about it but won’t deep-track unless you setdeep: true) - Anything destructured from a
reactiveobject loses reactivity:const { count } = state; countis no longer reactive.
The last point is the #1 source of bugs — see ref vs reactive vs shallowRef / shallowReactive.
How does ref differ structurally?
A ref is a wrapper object with a .value property. The Proxy is on the wrapper, not the value:
// Conceptual
class RefImpl<T> {
private _value: T;
private dep = new Set<Effect>();
constructor(value: T) { this._value = value; }
get value(): T { track(this, "value"); return this._value; }
set value(v: T) { this._value = v; trigger(this, "value"); }
}For object/array values, ref internally calls reactive on the value so deep mutations also trigger. Primitives can’t be Proxy’d (can’t trap reads on a number), which is why ref exists at all — the wrapper is the trackable object.
Why does effectScope exist?
Collects multiple effects so you can dispose them as a group. Useful in composables and Pinia stores so the effects get cleaned up when the owner is unmounted.
import { effectScope } from "vue";
const scope = effectScope();
scope.run(() => {
watch(...);
watchEffect(...);
computed(...);
});
scope.stop(); // stops all of the aboveIn <script setup> it’s done for you on unmount. In a custom store / standalone composable, you may need it explicitly.
What does markRaw do, and why?
Tells Vue never make this object reactive. Useful for objects you don’t want tracked (large class instances, third-party objects, ML model instances) — avoids the cost of proxying and avoids Proxy semantics breaking the object.
import { markRaw, reactive } from "vue";
const state = reactive({
foo: 1,
hugeThing: markRaw(new HugeClass()),
});Counterpart: toRaw(reactive) retrieves the original target.
What’s shallowReactive / shallowRef?
Reactivity only at the first level. Nested objects/arrays are not converted to reactive. Useful when:
- You don’t care about deep changes (you only ever replace the whole nested value).
- You’re storing immutable structures (the value reference identity is what matters).
- Performance — proxy traversal on deep structures is non-zero.
See ref vs reactive vs shallowRef / shallowReactive.
Gotchas / edge cases
- Destructuring loses reactivity.
const { count } = reactive({ count: 0 })—countis a plain number. UsetoRefs(state)to destructure with reactivity preserved, or passstatearound. - Reactivity is per-Proxy.
reactive(x) === reactive(x)is true (cached), butreactive({}) === reactive({})is false — different objects, different proxies.toRawlets you unwrap. - Replacing a
reactiveobject with=breaks tracking for the outer reference. The Proxy is the tracked thing; if you reassign the variable to a new object, watchers tied to the old one don’t fire. watch(() => state)withoutdeep: trueonly triggers on the top-level reference, not on deep property changes. Vue handleswatch(() => state.someNested)correctly because the source is a function and tracking happens on each call.- Reactive
Mapkeys are referenced by raw value, but you should still usereactivekeys consistently — equality semantics get confusing. Proxydoesn’t trapfor...inorder changes as predictably as object iteration in plain JS — usually fine, but a niche bug source.
What a senior is expected to say 5
- “Vue 3 reactivity is
Proxy-based. Reads track the active effect; writes trigger dependent effects. Render is just an effect.” - “
refexists for primitives (can’t proxy a number) and as a wrapper for non-object values.reactiveis for objects; nested values are deep-proxied unlessshallowReactive.” - “Destructuring a
reactiveloses the connection to the proxy.toRefsis the escape hatch.” - “
Proxylets Vue 3 detect property addition, deletion, array index assignment, andMap/Setmutations — all the gaps Vue 2’sdefinePropertyhad.” - “
markRawto opt out (heavy class instances),shallowReactive/shallowRefto opt out of deep tracking.”
Cross-references
refvsreactiveAPI details: ref vs reactive vs shallowRef / shallowReactivecomputed/watch/watchEffect(the user-facing effect types): computed vs watch vs watchEffect- Performance —
shallowRef/markRaw: Vue Performance — v-memo, shallowRef, markRaw, defineAsyncComponent
Further reading
- Vue Reactivity in Depth: https://vuejs.org/guide/extras/reactivity-in-depth.html
- “Vue Mastery” reactivity course / Evan You’s “Building a reactivity system” talks
@vue/reactivitysource: https://github.com/vuejs/core/tree/main/packages/reactivity