Vue.js: Reactivity Deep Dive
Last updated: 2026-10-20
Understanding how Vue 3 reactivity works lets you write more efficient code and debug tricky reactivity problems. This lesson digs into the low-level implementation of Vue 3's reactivity system: Proxy interception, dependency collection, triggered updates, and the internals of ref / computed / watch.
Vue 3 replaces Vue 2's Object.defineProperty with the ES6 Proxy, which natively supports arrays, Map, and Set, with roughly 2x better performance. Once you understand the mechanics, you can avoid 5 common reactivity pitfalls.
1. What You'll Learn
- How Proxy intercepts get/set operations
- The role of the Reflect API in reactivity
- The core flow of track (dependency collection) + trigger (triggering updates)
- The internals of ref / reactive / computed
- The internal differences between watch and watchEffect
- Performance optimization: shallowRef / markRaw
- The root causes of 5 common reactivity failures
2. The "Frozen Page" Incident with a 100,000-Row Table
(1) The Pain: A 100,000-Item List Freezes the Page Once Wrapped in ref
Alice loaded a 10,000-row product table in the admin:
// ❌ Broken version: deep reactive over 100,000 rows
const { ref } = Vue
const products = ref([
{ id: 1, name: 'Product 1', price: 99, ... },
// ... 10,000 items ...
])
// Vue creates a Proxy for every property and nested object
// Memory usage: 100MB+
// First render: 3 seconds
// Scrolling: 10 FPS (janky)
The performance monitor showed:
"10,000 rows × 4 nested properties × Proxy overhead = 40,000 Proxies. Every property access fires track. Memory: 100MB. FPS: 10."
(2) The Vue shallowRef + markRaw Fix
const { shallowRef, markRaw } = Vue
// ✅ shallowRef: only tracks replacement of .value as a whole
const products = shallowRef([
{ id: 1, name: 'Product 1' },
// ... 10,000 items (never wrapped in a Proxy) ...
])
// ✅ markRaw: mark an object as never reactive (fastest)
const staticConfig = markRaw({
apiUrl: 'https://api.example.com',
version: '1.0'
})
// Replace the whole value
products.value = await fetchProducts() // triggers exactly 1 update
(3) The Payoff
After optimizing:
- Number of Proxies: 40,000 → 0 (shallowRef creates none)
- Memory usage: 100MB → 5MB
- First render: 3s → 200ms
- FPS: 10 → 60 (smooth)
3. Proxy Interception: The Cornerstone of Vue 3 Reactivity
(1) What Is a Proxy?
A Proxy is an object wrapper introduced in ES6—it can intercept 13 kinds of operations on an object (get / set / has / deleteProperty, and others).
// Basic Proxy example
const target = { name: 'Alice', age: 25 }
const proxy = new Proxy(target, {
get(target, key) {
console.log(`Read ${key}`)
return Reflect.get(target, key)
},
set(target, key, value) {
console.log(`Write ${key} = ${value}`)
return Reflect.set(target, key, value)
}
})
proxy.name // logs "Read name", returns 'Alice'
proxy.age = 26 // logs "Write age = 26"
(2) Vue 3's reactive Implementation (Simplified)
// Simplified version of the reactivity package inside Vue 3
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
// 1. Dependency collection: record who is using this property
track(target, key)
// 2. Recursive wrapping: nested objects become reactive too
const value = Reflect.get(target, key, receiver)
if (isObject(value)) {
return reactive(value)
}
return value
},
set(target, key, value, receiver) {
// 1. Set the value
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
// 2. Trigger updates: notify everyone using this property
if (oldValue !== value) {
trigger(target, key)
}
return result
}
})
}
(3) Proxy vs Object.defineProperty
| Dimension | Object.defineProperty (Vue 2) | Proxy (Vue 3) |
|---|---|---|
| Array support | Needs special handling | Native support |
| Map/Set | ❌ | ✅ |
| Adding new properties | Needs Vue.set | Reactive automatically |
| Performance | Average | Better (lazy proxying) |
| Browser support | IE9+ | No IE support (needs a polyfill) |
▶ Example: Reactivity utilities (isRef/isReactive/toRaw/triggerRef)
▶ Expand runnable example (70 lines)
Output:
Three bordered panels stack vertically. Panel 1 shows true, true, false in green tags.
Panel 2 shows state.count = 0 and a button that increments it to 1, 2, 3… Panel 3 shows users[0]: Alice;
clicking the first button changes the data to Bob but the page still says Alice, while clicking Trigger manually with triggerRef re-renders and shows Bob.
1. isRef / isReactive checks
Is count a ref? [true]
Is state reactive? [true]
Is plain a ref? [false]
2. toRaw gets the original object (strips the Proxy)
state.raw === state? [false]
state.count = 0
[ +1 to state.count ] → 1, 2, 3 ...
3. shallowRef (shallow) + triggerRef (manual trigger)
users[0]: Alice
[ Change users[0].name (no update) ] → still shows Alice
[ Trigger manually with triggerRef ] → now shows Bob
4. track + trigger: Collecting Dependencies and Triggering Updates
(1) The Complete Flow
sequenceDiagram
participant T as Template
participant C as Component
participant R as Reactive<br/>(Proxy)
participant W as WeakMap<br/>(target → Map)
participant D as Data
T->>C: 1. Render (read count)
C->>R: 2. get count
R->>W: 3. track(target, 'count')
W->>W: 4. Record the current activeEffect
R->>D: 5. Read 0
R-->>C: 6. Return 0
C-->>T: 7. Display "Stock: 0"
Note over D,T: The user clicks the button
D->>R: 8. set count = 1
R->>W: 9. trigger(target, 'count')
W->>W: 10. Find all dependencies
R->>C: 11. Schedule an update
C-->>T: 12. Display "Stock: 1"
(2) The Global Dependency Storage Structure
// Vue 3's internal data structure
const targetMap = new WeakMap()
// targetMap = { obj1: Map1, obj2: Map2, ... }
// Map1 = { key1: Set1, key2: Set2, ... }
// Set1 = Set<activeEffect1, activeEffect2, ...>
function track(target, key) {
// 1. Get the Map for this target
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
// 2. Get the Set for this key
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
// 3. Record the currently active effect
if (activeEffect) {
dep.add(activeEffect)
}
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
// Notify every dependency to run again
dep.forEach(effect => effect.run())
}
}
(3) 5 Key Takeaways
| Point | Description |
|---|---|
| track on get | Reading a property → record "who uses it" |
| trigger on set | Changing a property → notify "everyone who uses it" |
| WeakMap storage | Automatic garbage collection (cleaned up when the target is destroyed) |
| activeEffect | The effect currently running (render / watch / computed) |
| Batching | Multiple mutations trigger only 1 update (nextTick) |
5. How ref Works Internally
(1) The ref Source (Simplified)
// Vue 3's internal ref implementation
class RefImpl {
constructor(value) {
this._value = isObject(value) ? reactive(value) : value
}
get value() {
// 1. track
track(this, 'value')
return this._value
}
set value(newValue) {
// 1. Compare old and new values
if (hasChanged(this._value, newValue)) {
this._value = isObject(newValue) ? reactive(newValue) : newValue
// 2. trigger
trigger(this, 'value')
}
}
}
function ref(value) {
return new RefImpl(value)
}
(2) How Auto-Unwrapping Works
The mechanism: when compiling the template, the compiler automatically appends .value to refs (you don't write it yourself).
6. How computed Works Internally
(1) The lazy + dirty Flags
// Simplified implementation of Vue 3 computed
function computed(getter) {
const ref = {
// 1. Cached value
_value: undefined,
// 2. Whether it's stale (needs recomputing)
_dirty: true,
// 3. getter
get value() {
// Lazy evaluation: only compute on first access
if (this._dirty) {
this._value = effect(getter) // collect dependencies
this._dirty = false
}
// 4. track (who is using me)
track(this, 'value')
return this._value
}
}
// 5. Mark dirty when a dependency changes
effect(() => getter()).scheduled = () => {
ref._dirty = true
trigger(ref, 'value') // trigger components that use this computed
}
return ref
}
(2) The Caching Mechanism
const count = ref(0)
const double = computed(() => count.value * 2)
console.log(double.value) // 0 (first computation)
console.log(double.value) // 0 (cached, not recomputed)
count.value = 5
console.log(double.value) // 10 (recomputed)
7. Performance Optimization: shallowRef / shallowReactive / markRaw
(1) shallowRef: Shallow Reactivity
const { shallowRef, triggerRef } = Vue
const products = shallowRef([
{ id: 1, name: 'iPhone', price: 999 },
{ id: 2, name: 'MacBook', price: 2499 }
])
// ❌ No reactivity (mutating a property inside .value)
products.value[0].name = 'iPad'
// No update is triggered (shallowRef only tracks replacing .value wholesale)
// ✅ Triggers an update (whole-value replacement)
products.value = [...products.value, { id: 3, name: 'iPad' }]
// ✅ Force it (trigger manually after an internal mutation)
products.value[0].name = 'iPad'
triggerRef(products)
(2) shallowReactive: A Shallow reactive
const { shallowReactive } = Vue
const state = shallowReactive({
user: { name: 'Alice' },
settings: { theme: 'dark' }
})
// ✅ Reactive: top-level property changes
state.user = { name: 'Bob' }
// ❌ Not reactive: nested properties
state.user.name = 'Charlie'
(3) markRaw: Permanently Non-Reactive
const { markRaw, reactive } = Vue
// Third-party library instances (ECharts, Mapbox) don't need reactivity
const map = markRaw(new Map())
const chart = markRaw(echarts.init(element))
const state = reactive({
map, // ✅ Never wrapped in a Proxy
chart // ✅ 30% faster
})
(4) 5 Use Cases
| Scenario | Approach | Performance gain |
|---|---|---|
| Large data lists (100k+) | shallowRef | 50x |
| Third-party library instances | markRaw | 30% |
| Objects created/destroyed frequently | shallowReactive | 20% |
| Read-only config | markRaw | 10% |
| Immutable data | shallowRef | 5% |
▶ Example: 5 Debugging Techniques (Recap)
const { triggerRef, isRef, isReactive, isReadonly, isProxy, toRaw, markRaw } = Vue
// 1. Print dependencies with watchEffect
watchEffect(() => console.log('deps:', { a: state.a, b: state.b }))
// 2. Trigger an update manually
triggerRef(stateRef)
// 3. Inspect reactivity state
isReactive(obj) // true/false
isRef(refObj) // true/false
// 4. Original object (Proxy removed)
const raw = toRaw(reactiveObj)
// 5. Performance marking
const fast = markRaw(expensiveObj)
Output:
A deps: { a: ..., b: ...
} line is logged once on setup and again whenever state.a or state.b changes.
isReactive / isRef return booleans, toRaw hands back the unwrapped object, and markRaw returns an object that Vue will never proxy.
deps: { a: 1, b: 2 } ← logged on setup, re-logged when state.a / state.b change
true ← isReactive(obj)
true ← isRef(refObj)
raw: plain object ← toRaw(reactiveObj)
fast: original reference ← markRaw(expensiveObj)
8. Putting It Together: A Hand-Written Reactivity System and ref Internals
▶ Example: track + trigger, minimal version (hand-written mini Vue)
▶ Try it YourselfOutput:
The heading Hand-written mini reactivity system renders with state.count = 0 beneath it.
Each click on the +1 button re-runs the tracked effect, so the number increases to 1, 2, 3… and the console logs Render: 1, Render: 2, and so on.
Hand-written mini reactivity system
state.count = 0
[ +1 (fires track/trigger) ]
console:
Render: 0 ← first activeEffect() call collects dependencies
Render: 1 ← click
Render: 2 ← click
(1) 📊 5 Performance Comparisons
| Pattern | 100k-row performance | Memory | Best for |
|---|---|---|---|
ref([]) |
100ms | 100MB | Small data |
reactive({...}) |
100ms | 100MB | Nested objects |
shallowRef([]) |
5ms | 5MB | Large lists |
shallowReactive({...}) |
30ms | 30MB | Shallow objects |
markRaw({...}) |
2ms | 2MB | Third-party instances |
▶ Example: Internal structure of ref + reactive
const { ref, reactive, shallowRef, markRaw, isRef, isReactive, toRaw } = Vue
// ref: wraps any value
const count = ref(0)
console.log(isRef(count)) // true
// reactive: wraps an object
const state = reactive({ count: 0 })
console.log(isReactive(state)) // true
// shallowRef: shallow
const list = shallowRef([1, 2, 3])
// markRaw: permanently non-reactive
const map = markRaw(new Map())
// toRaw: strips the Proxy
const raw = toRaw(state)
console.log(raw === state) // false
Output:
The console prints true, true, then false.
The last false proves that toRaw(state) returns the original object, which is not the same reference as the reactive proxy.
true ← isRef(count)
true ← isReactive(state)
false ← raw === state
(2) 📊 5 Root Causes of Lost Reactivity
| Broken case | Root cause | Fix |
|---|---|---|
| Replacing a reactive object wholesale | The reactive reference changed | Wrap it in ref or mutate a property |
| Destructuring loses reactivity | Destructuring extracts plain values | Use toRefs |
| Assigning to an array index directly | The internal property is intercepted by the Proxy | Use splice or replace the array |
| Async assignment doesn't react | The async function escapes track | Wrap it in ref/reactive |
| Nested object properties don't react | Fixed in Vue 3 (Vue 2 needed Vue.set) | Reactive automatically |
❓ FAQ
ref is deeply reactive (nested objects are proxied too). shallowRef only tracks replacement of .value and ignores internal properties. It's a good fit for large lists (100k+ rows).triggerRef(shallowRef) manually. Typical case: batch-mutate internal properties, then force a view refresh.Proxy { ... } wrapper and you can't see the original properties. Use toRaw(proxyObj) to get the raw object.📖 Summary
- Vue 3 reactivity core: Proxy interception + track (dependency collection) + trigger (update)
- Proxy beats Object.defineProperty: native array/Map/Set support, new properties react automatically
- ref internals: a RefImpl class that calls track/trigger in get/set
- computed internals: dirty flag + lazy evaluation + caching
- 5 performance optimizations: shallowRef / shallowReactive / markRaw
- 5 root causes of lost reactivity: replacement / destructuring / arrays / async / nesting
📝 Exercises
-
Basic (difficulty ⭐) Build a simplified reactivity system (based on Proxy):
- A 5-line reactive function
- 5 lines of track + trigger
- Demo: set 100 times → the effect runs once (batching)
-
Intermediate (difficulty ⭐⭐) Optimize a large list with shallowRef + markRaw:
- A 1000-item products list (using shallowRef)
- Include a third-party library instance (using markRaw)
- Measure the memory and FPS gains
-
Challenge (difficulty ⭐⭐⭐) Build a complete "dependency tracking debugger":
- Print all dependencies with watchEffect
- Trigger updates manually with triggerRef
- Demo the toRaw / isReactive / isRef utilities
- Root-cause analysis for 5 failure cases (ref replacement / destructuring / arrays / async / nesting)
- Benchmark memory: ref vs shallowRef vs markRaw