How Reactivity Works

Vue records which properties your code reads, then re-runs that code when one of them changes.

Syntaxreactive(obj) -> Proxy with get -> track(target, key) and set -> trigger(target, key)

Vue's reactivity is bookkeeping. It records which properties a piece of code read, and re-runs that code when one of those properties changes. Knowing the mechanism lets you predict behaviour instead of guessing at it.

Proxies, refs and effects

A proxy is a JavaScript object that wraps another object and intercepts operations on it. reactive() returns such a proxy. Its get trap calls an internal track before returning the value; its set trap calls trigger after writing. A ref is not a proxy at the top level: it is an object with a value getter and setter that call those same two functions.

An effect is a function Vue runs while it is recording reads. While the effect runs, Vue holds it as the active effect, and every track adds it to a subscriber set stored per target and per key. trigger re-runs the subscribers for that one key. A component's render function, a computed() getter, watchEffect() and a watch() source getter are all effects.

What follows from it

Dependencies are per property and re-collected on every run. A property behind an if that did not run this time is not a dependency yet. Reading a property outside any effect tracks nothing at all.

In a template {{ count }} is shorthand for {{ count.value }}: the compiler unwraps refs from the setup scope. In script there is no shorthand, so you write .value yourself.

Example

Example · vue
<script setup lang="ts">
import { reactive, computed } from 'vue'

interface Item {
  id: number
  label: string
}

interface Cart {
  items: Item[]
  coupon: string
}

const cart = reactive<Cart>({
  items: [{ id: 1, label: 'Lamp' }],
  coupon: ''
})

let nextId = 2

// This getter reads cart.items.length, so the computed subscribes to that
// array only. Writing cart.coupon never re-runs it.
const itemCount = computed<number>(() => cart.items.length)

function addItem(): void {
  cart.items.push({ id: nextId++, label: 'Shelf' })
}

function applyCoupon(): void {
  cart.coupon = 'SPRING'
}
</script>

<template>
  <p>Items: {{ itemCount }}</p>
  <p>Coupon: {{ cart.coupon || 'none' }}</p>
  <ul>
    <li v-for="item in cart.items" :key="item.id">{{ item.label }}</li>
  </ul>
  <button @click="addItem">Add item</button>
  <button @click="applyCoupon">Apply coupon</button>
</template>

<style scoped>
button {
  margin-right: 0.5rem;
}
</style>

When to use it

  • Working out why a component re-renders on one state change but not another
  • Explaining to a teammate why destructuring a reactive object quietly stops working
  • Deciding whether a value needs ref, reactive or no reactivity at all
  • Reading a stack trace that passes through a render effect or a computed getter

More examples

Reactivity in about twenty lines

The real implementation handles arrays, Maps, nested objects, batching and cleanup, but this is the shape of it: a get trap that records the active effect against a key, and a set trap that re-runs the recorded effects. Read this once and the rest of the stage stops being magic.

Example · js
// src/demo/mini-reactivity.js
// A sketch of what reactive() and ref() do, with one dependency map.
let activeEffect = null
const deps = new WeakMap()

function track(target, key) {
  if (!activeEffect) return
  let keys = deps.get(target)
  if (!keys) deps.set(target, (keys = new Map()))
  let subscribers = keys.get(key)
  if (!subscribers) keys.set(key, (subscribers = new Set()))
  subscribers.add(activeEffect)
}

function trigger(target, key) {
  const subscribers = deps.get(target)?.get(key)
  if (subscribers) for (const effect of [...subscribers]) effect()
}

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)
      return true
    }
  })
}

function effect(fn) {
  const run = () => {
    activeEffect = run
    fn()
    activeEffect = null
  }
  run()
}

const state = reactive({ total: 1 })
effect(() => console.log('total is', state.total))
// Output: total is 1
state.total = 4
// Output: total is 4

Tracking is per property, not per object

The getter never read `state.b`, so writing it invalidates nothing and the cached value is returned unchanged. Reach for this when a computed or a component seems to update too often: the question is always which properties were read during the last run.

Example · ts
// src/demo/per-property.ts
import { reactive, computed } from 'vue'

const state = reactive({ a: 1, b: 1 })

// The counter exists only to show how often the getter runs.
// Real computed getters stay free of side effects.
let runs = 0
const doubled = computed(() => {
  runs += 1
  return state.a * 2
})

console.log(doubled.value, runs)
// Output: 2 1

state.b = 99
console.log(doubled.value, runs)
// Output: 2 1

state.a = 5
console.log(doubled.value, runs)
// Output: 10 2

The two ways tracking is lost

Both failures follow from the mechanism: tracking happens in the proxy's traps, so a plain local variable or a reassigned binding is outside the system. This is why a reactive object is mutated in place and never destructured or replaced.

Example · js
// src/demo/lost-tracking.js
import { reactive } from 'vue'

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

console.log(state === raw)
// Output: false

// Destructuring copies the current value out of the proxy.
let { count } = state
count += 1
console.log(state.count)
// Output: 0

// Mutating through the proxy goes through the set trap.
state.count += 1
console.log(state.count)
// Output: 1

Discussion

  • Be the first to comment on this lesson.