Vue 3 Proxy Reactivity: How the Reactivity Core Really Works Under the Hood
AI generated
<v/>
{ }
Vue.js · Reactivity · JavaScript Proxy · Internals
Vue 3 Proxy Reactivity
how the reactivity core really works

Behind every reactive() call sits an ES2015 Proxy with get and set traps, a WeakMap dependency graph and a fine grained track/trigger system. Anyone who truly understands Vue 3 proxy reactivity immediately sees why destructuring breaks reactivity and why some objects never become reactive.

18 min read Proxy · track/trigger · WeakMap · dependency graph Vue 3.x · @vue/reactivity

1. Why Proxy instead of Object.defineProperty

Vue 2 built its reactivity on Object.defineProperty(), converting each object property individually into a getter and setter. That worked, but it had two structural limits. New properties added after initialization stayed non reactive, because nobody had defined a getter and setter for them. And array indices could not be intercepted efficiently with defineProperty, which is why Vue 2 had to patch array mutation methods such as push and splice. Vue 3 proxy reactivity solves both problems fundamentally, because a proxy does not depend on individually known properties in advance.

A Proxy in modern JavaScript wraps a complete object and intercepts operations such as reading, writing, deleting and existence checks through so called traps, no matter which property is involved. That makes Vue 3 proxy reactivity complete by nature: a newly added property, a deleted property and an array index are all captured through the same mechanism. This not only reduces special cases inside the framework code itself, it also simplifies the mental model for developers, because reactive behavior no longer depends on the kind of mutation performed.

2. How reactive() creates a Proxy internally

When you call reactive(obj), Vue first checks whether obj is already a reactive proxy or already has an associated proxy, to avoid double wrapping. If not, Vue creates a new Proxy(target, handler) with a handler object implementing get, set, has, deleteProperty and ownKeys. The get trap calls the function track(target, key) on every read access, registering the currently running effect as a dependency of that specific target and key combination. This is exactly where the fine granularity of Vue 3 proxy reactivity emerges: the whole object is never marked as a dependency, only the exact property that was read.

The set trap first checks whether the new value even differs from the old one, using an Object.is() comparison, to avoid unnecessary updates. If the value changes, it calls trigger(target, key), which re runs every effect registered for that key. Nested objects are not immediately turned into proxies recursively, they are converted lazily, only when a nested property is actually read. The get trap checks whether the returned value is an object and wraps it in reactive() only at that point. This deferred conversion saves computation time for large, deeply nested data structures that are never fully read.


// Simplified excerpt from @vue/reactivity — baseHandlers.ts
function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)

    if (!isReadonly) {
      // Track the current active effect as a dependency of target+key
      track(target, TrackOpTypes.GET, key)
    }

    if (shallow) {
      return res
    }

    // Lazy nested reactivity: only wrap objects when actually read
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

function createSetter() {
  return function set(target, key, value, receiver) {
    const oldValue = target[key]
    const result = Reflect.set(target, key, value, receiver)

    // Only trigger effects if the value actually changed
    if (!Object.is(value, oldValue)) {
      trigger(target, TriggerOpTypes.SET, key, value, oldValue)
    }

    return result
  }
}

3. Dependency tracking with track and trigger

The core of Vue 3 proxy reactivity is a three tier data structure: a global WeakMap that maps every target object to a Map, which in turn maps every key to a Set of effects. This structure is internally called targetMap. The WeakMap outer layer is deliberately chosen because it does not interfere with garbage collection. As soon as a target object is no longer referenced elsewhere, it can be removed from memory together with its dependencies, without Vue having to clean up explicitly.

When track() is called during an active effect, for instance inside a computed property or a watchEffect, the effect is added to the matching Set for that object and key. When trigger() is called after a mutation, that Set is looked up and every contained effect is re run. Because effects are stored in sets, an effect that reads a property multiple times still registers as a dependency only once. This deduplication is a key reason why Vue 3 proxy reactivity stays efficient even in complex templates with many read accesses.


// Simplified excerpt — the core dependency graph
const targetMap = new WeakMap()

function track(target, type, key) {
  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)
  activeEffect.deps.push(dep)
}

function trigger(target, type, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return // Never tracked, nothing to do

  const dep = depsMap.get(key)
  if (dep) {
    // Copy into an array before iterating: effects may re-add themselves
    const effects = [...dep]
    effects.forEach((effect) => {
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        effect.run()
      }
    })
  }
}

4. Special cases: arrays, Map and Set

Arrays need special treatment in Vue 3 proxy reactivity, because methods such as push, pop and splice internally read and write both the length property and several indices at once. Without adjustment, push() would trigger track for length multiple times and accidentally provoke infinite loops with concurrently active effects. Vue therefore instruments exactly these array methods in a separate handler that briefly disables tracking during the method call and then fires exactly one trigger for the relevant changes.

For Map, Set, WeakMap and WeakSet a similar challenge exists, because their native methods such as get, set, has and size do not run through normal property access, they use internal slots that a proxy cannot intercept directly. Vue ships a dedicated collectionHandlers module for this, which replaces these methods and manually calls track and trigger. Anyone building a custom data structure on top of Map reactive needs to know this detail, otherwise they will wonder why reactiveMap.get('key') does not react as expected in a template if the native object is bypassed directly.

5. ref() as a proxy special case

Unlike reactive(), ref() does not internally use a proxy for primitive values such as numbers or strings, because a proxy must always wrap an object, and primitive values are not objects in JavaScript. Instead, ref() is a class called RefImpl with a get value() and set value() accessor that manually calls trackRefValue and triggerRefValue, a kind of hand written miniature of Vue 3 proxy reactivity without an actual proxy. However, if you pass ref() an object, Vue internally converts that object with reactive() and stores the resulting proxy in RefImpl.value.

That explains a common point of confusion: const r = ref({ count: 0 }); console.log(r.value === reactive(r.value)) yields true, because both accesses point to the same cached proxy. Inside a <template> block, Vue removes .value automatically through a compiler transformation called ref unwrapping, but inside <script setup> code, .value always has to be written explicitly, except for nested refs inside a reactive() object, where Vue also handles unwrapping automatically.

6. readonly() and shallowReadonly()

readonly() also creates a proxy, but uses a different set trap that intercepts every write operation, logs a warning to the console and silently discards the write. Internally, Vue uses the same get trap generator as reactive(), but with the flag isReadonly = true, which skips track(), because a read only object by definition never changes and therefore needs no tracking. This optimization of Vue 3 proxy reactivity saves noticeable overhead for large, immutable data such as configuration objects.

shallowReadonly() goes one step further and prevents the recursive conversion of nested objects into further read only proxies. Only the top level is protected, deeper nested objects stay mutable in their original state. This pattern is particularly suited for props that should not be mutated as an external interface, but whose internal structure does not need to be fully wrapped in nested proxies for performance reasons. The difference between deep and shallow read only demonstrates how granularly Vue 3 proxy reactivity can be configured, depending on how much protection and tracking is actually needed.

7. Detecting and avoiding reactivity loss

The most common mistake when working with Vue 3 proxy reactivity is destructuring a reactive object: const { count } = reactiveState copies the current value of count into a new, independent variable that has no connection to the proxy anymore. The reason lies in the nature of JavaScript itself: destructuring reads the value once, it does not create a reference to the proxy property. The only solution is toRefs(), which creates an individual ref from every property of a reactive object, staying internally connected to the original proxy through get/set.

A second pitfall is Object.assign(reactiveState, newData) combined with subsequent destructuring: the assign itself works correctly and reactively, because it runs through the proxy set trap, but any destructuring performed afterwards still remains a snapshot. Comparing rawObject === reactiveProxy is also a common mistake: a reactive proxy is never identical to its original target object, which is why identity comparisons between the raw and the reactive version almost always return false, even if both objects contain the same values. With toRaw() the original, unwrapped object can be retrieved on purpose, for instance to pass it unchanged to an external library that does not expect proxy traps.


import { reactive, toRefs, toRaw, isReactive } from 'vue'

const state = reactive({ count: 0, name: 'Vue' })

// WRONG: destructuring breaks the connection to the proxy
const { count } = state
count // 0, frozen forever, never updates again

// RIGHT: toRefs keeps the live link via individual refs
const { count: countRef } = toRefs(state)
countRef.value // stays in sync with state.count

// Identity check: proxy is never the same object as the target
const raw = { count: 0 }
const wrapped = reactive(raw)
console.log(raw === wrapped) // false

// toRaw() retrieves the original, unwrapped object
console.log(toRaw(wrapped) === raw) // true
console.log(isReactive(toRaw(wrapped))) // false

8. Debugging with onTrack and onTrigger

Because Vue 3 proxy reactivity works entirely implicitly, it is hard to observe without tools. watchEffect and computed accept optional debug hooks for this: onTrack is called every time a reactive dependency is registered, onTrigger every time a registered dependency re triggers the effect. Both callbacks receive an event object with target object, key and operation type, making it possible to see exactly which property triggered which effect.

In practice these hooks are especially valuable for unexpected re renders: instead of guessing which of ten reactive properties re triggers an expensive computation, you temporarily set onTrigger(e) { debugger } and inspect directly in the browser debugger which mutation is responsible. Vue DevTools use these same hooks internally to populate the reactivity tab, which visualizes the dependency graph. Anyone debugging Vue 3 proxy reactivity in large composables should know these hooks instead of relying exclusively on trial and error with console.log.

9. Proxy reactivity compared to the old implementation

The move from Object.defineProperty to Proxy was not a cosmetic change, it has concrete consequences for completeness, performance and browser support. The following table compares the most important differences between the Vue 2 implementation and Vue 3 proxy reactivity.

Aspect Vue 2 (Object.defineProperty) Vue 3 (Proxy)
New properties Not reactive, requires Vue.set() Automatically reactive via set trap
Array indices Requires patched mutation methods Captured directly via proxy trap
Initialization cost Recursive on creation, all levels Lazy, only on actual read
Deleting a property Not detected deleteProperty trap catches it
Browser support IE9+ No IE, Proxy cannot be polyfilled

The only real downside of Vue 3 proxy reactivity is losing Internet Explorer support, because the Proxy mechanism cannot be fully polyfilled for language design reasons. For every modern deployment target, though, the benefits clearly outweigh this: complete capture of mutations, lower initialization cost through lazy conversion, and one consistent mental model for objects, arrays and collections.

Mironsoft

Vue 3 and Nuxt development with a focus on clean reactivity architecture

Reactivity bugs nobody on the team can explain?

We analyze existing Vue 3 codebases for reactivity loss, unnecessary re renders and fragile composables, and build a maintainable, well documented reactivity foundation from it.

Reactivity audit

Checking the codebase for reactivity loss and inefficient proxy usage

Composable refactoring

Integrating toRefs, customRef and EffectScope correctly into existing composables

Performance tuning

shallowRef and targeted tracking for large, nested data structures

10. Summary

Vue 3 proxy reactivity replaces the property by property instrumentation of Vue 2 with a single, complete wrapper mechanism. reactive() creates a Proxy with get and set traps that internally call track() and trigger(), maintaining a precise, three tier dependency graph in a WeakMap. Arrays, Map and Set each need their own specialized handlers, because their native methods do not run through normal property access.

ref() does not use an actual proxy for primitive values, instead relying on a hand written RefImpl class with the same track/trigger principles. Reactivity loss almost always arises from destructuring, which toRefs() specifically prevents. readonly() and shallowReadonly() show how granularly protection and tracking can be configured. With onTrack/onTrigger, every dependency can be observed in detail, without relying on trial and error.

Vue 3 Proxy Reactivity — the essentials at a glance

Core mechanism

reactive() creates a Proxy with get/set traps that call track() and trigger() in a WeakMap structure.

Special cases

Arrays, Map and Set need their own handlers because their native methods use internal slots instead of property access.

Reactivity loss

Destructuring copies values, not references. toRefs() is the only safe fix for that.

Debugging

onTrack and onTrigger in watchEffect/computed show exactly which property triggers which effect.

11. FAQ: Vue 3 Proxy Reactivity

1Why Proxy instead of Object.defineProperty?
A Proxy intercepts all operations, regardless of the individual property. New properties, deleted properties and array indices are captured automatically.
2What exactly does track() do?
Registers the active effect as a dependency of a target object and key, stored in the WeakMap structure targetMap.
3Why WeakMap instead of Map?
Prevents artificially keeping objects alive. Unused target objects are removed together with their dependencies by the garbage collector.
4Why does destructuring break reactivity?
It copies the current value once, without a connection to the proxy. toRefs() creates real, live connected refs instead.
5Why no real proxy in ref()?
Primitive values are not objects and cannot be wrapped by a proxy. RefImpl replicates track/trigger manually instead.
6Why custom handlers for Map and Set?
Native methods use internal slots instead of normal property access. Vue replaces them specifically in the collectionHandlers module.
7readonly() vs. shallowReadonly()?
readonly() protects all levels recursively, shallowReadonly() only the top level. Deeper nested objects stay mutable with shallowReadonly().
8How do I observe track/trigger events?
With the debug hooks onTrack and onTrigger in watchEffect or computed, which deliver a detailed event object.
9Why is Proxy !== original?
The proxy is a separate wrapper object. A === comparison with the original therefore always returns false.
10When to use toRaw()?
When an object needs to be passed unchanged to external libraries that do not expect proxy behavior.