Frontend / Vue / 02_reactivity_internals.md

Vue Reactivity Internals — Proxy, track/trigger, Effects

Updated 5 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

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:

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 / 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 5

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