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.
Interview Q&A
Q: How does reactive() actually work?
A: 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.
Q: What’s an “effect” in Vue’s reactivity system?
A: 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.
Q: Why Proxy and not Object.defineProperty (the Vue 2 way)?
A: 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).
Q: What gets tracked, and what doesn’t?
A:
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 03_ref_vs_reactive.md.
Q: How does ref differ structurally?
A: 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.
Q: Why does effectScope exist?
A: 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 above
In <script setup> it’s done for you on unmount. In a custom store / standalone composable, you may need it explicitly.
Q: What does markRaw do, and why?
A: 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.
Q: What’s shallowReactive / shallowRef?
A: 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.
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
- “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: 03_ref_vs_reactive.mdcomputed/watch/watchEffect(the user-facing effect types): 04_computed_watch_watcheffect.md- Performance —
shallowRef/markRaw: 13_performance.md
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