frontend / vue / 02_reactivity_internals.md

Vue Reactivity Internals — Proxy, track/trigger, Effects

6 min read source

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:

  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.

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

Q: What gets tracked, and what doesn’t?

A:

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

See 03_ref_vs_reactive.md.

Gotchas / edge cases

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

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.”
  • ref exists for primitives (can’t proxy a number) and as a wrapper for non-object values. reactive is for objects; nested values are deep-proxied unless shallowReactive.”
  • “Destructuring a reactive loses the connection to the proxy. toRefs is the escape hatch.”
  • Proxy lets Vue 3 detect property addition, deletion, array index assignment, and Map/Set mutations — all the gaps Vue 2’s defineProperty had.”
  • markRaw to opt out (heavy class instances), shallowReactive/shallowRef to opt out of deep tracking.”

Cross-references

Further reading