Skip to content
Deep diveAdvanced Vue

Reactivity in depth: proxies, effects and why Vue knows what changed

Vue does not watch your variables. It wraps your objects in a Proxy, remembers which function read which property, and re-runs exactly those functions. Here is the whole machine, small enough to type out.

vuejs.school team14 Aug 20258 min readVue 3.5

You change state.count and the page updates. Nothing in your code called a render function, and no timer polls for changes. Three pieces make that happen: a Proxy that notices reads and writes, a global "which function is running right now" variable that turns reads into subscriptions, and a queue that turns many writes into one update. Once you can see them, the common reactivity surprises become predictable: why a destructured property goes stale, why nextTick exists, why a Map works and an object you kept a reference to does not.

Everything here describes Vue 3.5, whose current release is 3.5.18.

A reactive object is a Proxy

reactive() does not copy your object or add hidden fields to it. It returns a Proxy, a built-in JavaScript object that stands in front of yours and lets you intercept operations on it. Vue installs handlers, called traps, for the operations it cares about: get, set, has, deleteProperty and ownKeys.

import { reactive } from 'vue'

const raw = { count: 0 }
const state = reactive(raw)

state === raw      // false: state is the proxy, raw is your object
state.count        // goes through the get trap, then reads raw.count
state.count = 1    // goes through the set trap, then writes raw.count
raw.count          // 1: the data still lives in your object

The get trap does two things. It records that the property was read, and it returns the value. If the value is itself an object, the trap wraps it in another proxy before returning it, which is why nested state is reactive without you doing anything. The wrapping happens lazily, on access, and Vue caches it, so state.user === state.user stays true.

The set trap also does two things. It writes the new value, and if the value actually changed it tells everyone who read that property to run again.

Two consequences follow straight from the design:

  • The proxy only sees operations that go through it. Write raw.count = 5 and nothing updates, because raw is not the proxy.
  • A property copied out of the proxy is just a value. const { count } = state gives you the number 0, and count cannot change later, because numbers do not change. toRefs(state) exists for exactly this case: it returns refs that read through the proxy every time.

Build tracking and triggering in forty lines

The part that turns "someone read a property" into "re-run this function" is small enough to build. This is not Vue's code, and it leaves out several things Vue handles, but the shape is the same:

reactive.ts
type Effect = () => void

let activeEffect: Effect | null = null
const targetMap = new WeakMap<object, Map<PropertyKey, Set<Effect>>>()

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)
  if (!dep) return
  for (const effect of [...dep]) effect()
}

export function reactive<T extends object>(target: T): T {
  return new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key)
      return Reflect.get(obj, key, receiver)
    },
    set(obj, key, value, receiver) {
      const changed = Reflect.get(obj, key, receiver) !== value
      const ok = Reflect.set(obj, key, value, receiver)
      if (changed) trigger(obj, key)
      return ok
    },
  })
}

export function effect(fn: Effect) {
  const run: Effect = () => {
    const previous = activeEffect
    activeEffect = run
    try { fn() } finally { activeEffect = previous }
  }
  run()
}

Read it bottom up. effect(fn) runs fn once, and while it runs, activeEffect points at it. Every get trap that fires during that run calls track, which looks up the set of effects for that object and property and adds the running effect to it. The nested structure, a WeakMap of objects to a Map of keys to a Set of effects, is the dependency graph. WeakMap means an object nobody references any more can be garbage-collected along with its subscriptions.

trigger is the other half. The set trap calls it with the object and the key, it finds the set of effects that read that key, and runs each one.

The loop runs over a copy, [...dep]. In this version the copy is only a precaution: a re-run adds the same effect to the set again, and adding a value a Set already holds changes nothing. It becomes necessary once effects forget dependencies they no longer read. The simplest way to do that is to remove an effect from every set before it runs. The effect is then deleted and added back while the loop is still going, and a loop over the live Set reaches it again, forever.

Try it in a file next to reactive.ts:

import { effect, reactive } from './reactive'

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

effect(() => {
  console.log(`count is ${state.count}`)
})
// logs "count is 0"

state.count++         // logs "count is 1"
state.name = 'Grace'  // logs nothing: the effect never read name

That last line is the point. Vue does not diff your state or compare old and new trees of data. It knows which effect read which property, so it knows exactly what to run.

Vue's real implementation adds a few things, none of which changes the model:

  • Cleanup of dependencies an effect no longer reads (an if branch that stopped being taken).
  • A guard against an effect triggering itself.
  • Handling for arrays (push touches length and an index at once).
  • In 3.5, a rework of the data structures, with version counters and linked lists in place of plain sets. The 3.5 release post reports that the rework cut the memory used by reactivity by 56 percent.

Refs use the same model without a proxy. A ref is an object with a value getter that tracks the read and a value setter that triggers. A proxy can only wrap objects, so .value is the price of making a single number trackable. Put an object in a ref, ref({ a: 1 }), and Vue stores reactive({ a: 1 }) inside it, so the contents are deeply reactive too.

Render, computed and watch are all effects

Three features you use every day are built on this mechanism.

A component's render function runs inside an effect. Every property the template reads during render is tracked for that component. When one of them changes, the component's update job is triggered, and only that component re-renders. No parent re-render, no manual memoisation.

A watchEffect is the plain form, the closest thing to the effect above that you call yourself:

import { reactive, watchEffect } from 'vue'

const state = reactive({ first: 'Ada', last: 'Lovelace', showLast: false })

watchEffect(() => {
  document.title = state.showLast ? `${state.first} ${state.last}` : state.first
})

While showLast is false, the effect never reads last, so changing last does nothing. Flip showLast to true, the effect re-runs, reads last, and from then on depends on it. Dependencies are collected on every run, not once.

A computed works like an effect with a cache and a flag. The first time you read its .value, the getter runs, tracks its dependencies, stores the result and marks itself clean. Reading it again returns the stored value without running anything.

When a dependency changes, the computed is not recomputed. It is only marked dirty, and anything that read the computed is notified. The next read sees the dirty flag, runs the getter and caches again. That is why a computed nobody reads costs nothing when its inputs change, and why side effects inside a computed getter run at surprising times: they run when something reads the value, not when the inputs change.

A notified effect does not always run again. Before re-running it, Vue reads the computed: if the new result equals the old one, the effect is skipped. A component that shows only an isEven computed does not re-render when the count goes from 2 to 4.

Why two writes cause one render

Nothing above says when an effect runs. In the forty-line version it runs immediately, inside the set trap, which would re-render a component once per assignment. Vue does not do that. Component updates, and watchers by default, go through a scheduler.

When a render effect is triggered, Vue pushes its update job onto a queue, deduplicated by job, and schedules a flush with an already-resolved promise. The flush therefore happens in a microtask: after the current synchronous code finishes, before the browser paints. All the writes you make in one event handler land before the flush, and the component renders once with the final values.

import { ref, watch } from 'vue'

const count = ref(0)
watch(count, (value) => console.log('watch saw', value))

function handleClick() {
  count.value = 1
  count.value = 2
  console.log('handler done')
}
// prints: handler done, then watch saw 2

When handleClick() runs, watch sees 2, once. It never sees 1, because the callback was queued rather than called, and by flush time the ref held 2.

The DOM works the same way: reading an element's text right after count.value = 2 gives you the old text. nextTick() returns the promise of the current flush, so await nextTick() is the honest way to wait for the DOM to match the state.

Watchers accept a flush option that sets when their callback runs:

  • 'pre', the default: before the component renders.
  • 'post': after the DOM has updated.
  • 'sync': immediately, with no queue. It brings back once-per-write behaviour, which is why it is rarely what you want.

What the proxy can and cannot see

Map, Set, WeakMap and WeakSet work in reactive state, but not through the same traps. Their data is not stored as properties; map.get('a') is a method call that reads internal slots a proxy cannot intercept. Vue handles them with a second set of handlers that replace get, set, has, add, delete, clear, forEach and the iterators with versions that call track and trigger, then forward to the original method. From your side, state.selected.add(id) triggers anything that iterated state.selected, as you would hope.

What does not work is anything that bypasses the proxy:

  • Mutating the raw object you passed to reactive(), as in the first example. Keep the proxy, drop the original.
  • Objects you excluded on purpose. markRaw(obj) tags an object so that reactive() returns it unwrapped. Use it for things that should not be proxied: a Three.js scene, a chart instance, a huge read-only dataset. Reading state.scene.children is then a plain read, and changing it updates nothing.
  • Properties inside a shallowReactive() or a shallowRef(). These track the first level only. shallowRef(list) tracks list.value = newList and ignores list.value.push(item). That is a feature when a list has ten thousand rows and you replace it wholesale after each fetch: no proxy per row, no deep traversal.

Vue 3.6 alpha: new internals, same model

Vue 3.6 is in alpha. The 3.6.0-alpha.1 release notes describe the reactivity core as refactored by porting alien-signals, a small signals library, and list the change under performance improvements. Vapor Mode, the other headline of 3.6, is a different compilation output and does not change how reactivity works either. Both are pre-releases, so keep your projects on 3.5.

The public API is the same: reactive, ref, computed, watch, watchEffect, shallowRef, markRaw. The model in this post, a proxy that tracks, an effect that subscribes, a scheduler that batches, is the model you will keep using. For a second pass at it, the official guide's Reactivity in Depth page covers the same ground from a different angle.

Check it stuck3 questions from the Reactivity in depth quiz.
Practice now
Was this helpful?

Keep reading