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


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:

JS
// ❌ 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

JS
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:


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).

JS
// 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)

JS
// 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)
▶ Try it Yourself

Output:

TEXT
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.
TEXT
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

100%
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

JS
// 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)

JS
// 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

JS
// 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

JS
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

JS
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

JS
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

JS
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)

JS
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)
▶ Try it Yourself

Output:

TEXT
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.
TEXT
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 Yourself

Output:

TEXT
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.
TEXT
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

JS
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
▶ Try it Yourself

Output:

TEXT
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.
TEXT
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

Q Why does Vue 3 use Proxy instead of Object.defineProperty?
A Proxy natively supports arrays, Map, and Set and can intercept more operations (13 vs 2). New properties become reactive automatically, and performance is better (lazy proxying). Its only downside is that it doesn't support IE11.
Q Where are track and trigger called?
A track is called in the get trap of a reactive object (recording dependencies on read). trigger is called in the set trap (notifying all dependencies on write).
Q What's the difference between shallowRef and ref?
A 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).
Q How does computed know its dependencies changed?
A computed wraps the getter in an effect. When reactive data used inside the getter changes, the effect fires, resets the dirty flag, and the next access recomputes.
Q How do watch and watchEffect differ internally?
A watch takes an explicit source (dependency) and the callback receives newVal/oldVal. watchEffect takes no source, tracks dependencies inside the function automatically, and the callback only receives the new value.
Q How do I trigger an update after mutating a shallowRef's internals?
A Call triggerRef(shallowRef) manually. Typical case: batch-mutate internal properties, then force a view refresh.
Q What does a reactive Proxy show in the console?
A It shows a Proxy { ... } wrapper and you can't see the original properties. Use toRaw(proxyObj) to get the raw object.

📖 Summary


📝 Exercises

  1. 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)
  2. 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
  3. Challenge (difficulty ⭐⭐⭐) Build a complete "dependency tracking debugger":

    1. Print all dependencies with watchEffect
    2. Trigger updates manually with triggerRef
    3. Demo the toRaw / isReactive / isRef utilities
    4. Root-cause analysis for 5 failure cases (ref replacement / destructuring / arrays / async / nesting)
    5. Benchmark memory: ref vs shallowRef vs markRaw
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏