How Reactivity Works
Vue records which properties your code reads, then re-runs that code when one of them changes.
reactive(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
<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.
// 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 4Tracking 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.
// 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 2The 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.
// 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