Frontend / Vue / 02_reactivity_internals.md

Vue Reactivity Internals — Proxy, track/trigger, Effects

Updated 5 interview angles 6 min read source
On this page12
  1. How does reactive() actually work?
  2. What’s an “effect” in Vue’s reactivity system?
  3. Why Proxy and not Object.defineProperty (the Vue 2 way)?
  4. What gets tracked, and what doesn’t?
  5. How does ref differ structurally?
  6. Why does effectScope exist?
  7. What does markRaw do, and why?
  8. What’s shallowReactive / shallowRef?
  9. Gotchas
  10. Related
  11. Further reading
  12. Interview angle

Vue Reactivity Internals — Proxy, track/trigger, Effects

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.

How does reactive() actually work?

It wraps the object in a Proxy with get and set handlers:

ts
// 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:

  1. Sets itself as the activeEffect.
  2. Runs the user code (which triggers get traps → calls track).
  3. Unsets activeEffect.
  4. 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/Set mutations.
  • 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 reactive object: state.count
  • .value reads on a ref: count.value
  • Iteration: for (const item of arr)
  • has: "foo" in state
  • keys/values/entries
  • Map/Set reads (map.get, set.has, iteration)

Not tracked:

  • Reads on the raw (unwrapped) object — toRaw(state).count
  • Reads inside a watch source function that’s () => state (the whole object, not a deep traversal — Vue’s still smart about it but won’t deep-track unless you set deep: true)
  • Anything destructured from a reactive object loses reactivity: const { count } = state; count is 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:

ts
// 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.

ts
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.

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.

ts
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

  • Destructuring loses reactivity. const { count } = reactive({ count: 0 }) — count is a plain number. Use toRefs(state) to destructure with reactivity preserved, or pass state around.
  • Reactivity is per-Proxy. reactive(x) === reactive(x) is true (cached), but reactive({}) === reactive({}) is false — different objects, different proxies. toRaw lets you unwrap.
  • Replacing a reactive object 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) without deep: true only triggers on the top-level reference, not on deep property changes. Vue handles watch(() => state.someNested) correctly because the source is a function and tracking happens on each call.
  • Reactive Map keys are referenced by raw value, but you should still use reactive keys consistently — equality semantics get confusing.
  • Proxy doesn’t trap for...in order changes as predictably as object iteration in plain JS — usually fine, but a niche bug source.

Further reading

Interview angle 5

  • “How does Vue 3 reactivity work?” - Proxy traps. A read during an active effect records that effect as a dependency of that property; a write triggers every effect that read it. Rendering is just another effect, which is why the component re-renders when its data changes and why nothing needs a setState.
  • “Why does ref exist if reactive proxies objects?” - because you cannot proxy a primitive. A number has no properties to trap, so ref wraps it in an object with a .value accessor and tracks that. It also gives you something reassignable, which a reactive object is not.
  • “You destructured a reactive object and lost reactivity. Why?” - destructuring reads the properties once and copies the values out, so what you hold is a plain value with no connection to the proxy. toRefs is the escape hatch: it converts each property into a ref that stays linked.
  • “What did Proxy fix that Vue 2 could not do?” - property addition and deletion, array index assignment, length changes, and Map/Set mutations. Object.defineProperty could only intercept keys that existed when the object was made, which is why Vue 2 needed Vue.set.
  • “How do you opt out of tracking?” - markRaw for something Vue should never proxy, such as a heavy class instance or a third-party object that breaks when wrapped, and shallowRef/shallowReactive when you only replace the value and do not need deep tracking.