Vue 3's reactivity system is a complete rewrite from Vue 2, built on ES2015 Proxy instead of Object.defineProperty. This is not a cosmetic change — it eliminates entire categories of Vue 2 limitations and introduces a different mental model that every ref/reactive decision downstream depends on.
Proxy vs Object.defineProperty
Object.defineProperty intercepts access to a property that already exists. Proxy intercepts operations on an entire object — property reads, writes, deletions, and the in operator — regardless of whether the property existed at observation time. This single distinction resolves Vue 2's two most notorious reactivity gaps.
| Criteria | Vue 2 (Object.defineProperty) | Vue 3 (Proxy) |
|---|---|---|
| New property addition | Invisible to reactivity — Vue.set() required | Fully reactive — Proxy's set trap fires for any key, existing or new |
| Array index assignment | arr[i] = x bypasses reactivity (no getter/setter installed per index) | Fully reactive — Proxy's set trap intercepts index writes directly |
| Delete detection | delete obj.key invisible — Vue.delete() required | Fully reactive via the deleteProperty trap |
| Initialization cost | Walks and wraps every property recursively upfront, even ones never read | Lazy — nested objects are only wrapped in a Proxy when actually accessed (see below) |
| Map/Set support | Not reactive without manual instrumentation | Natively reactive via dedicated collection traps |
Track and Trigger: The Dependency Graph
Every reactive read runs inside an implicit dependency-tracking context established by an active ReactiveEffect (a render function, a computed, a watchEffect). Reading a reactive property calls track(), which registers the currently active effect as a subscriber to that exact property key. Writing calls trigger(), which re-runs every subscribed effect.
// Simplified model of @vue/reactivity's core mechanism
type Dep = Set<ReactiveEffect>
const targetMap = new WeakMap<object, Map<PropertyKey, Dep>>()
let activeEffect: ReactiveEffect | undefined
function track(target: object, key: PropertyKey): void {
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): void {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
dep?.forEach((effect) => effect.run())
}
function reactive<T extends object>(target: T): T {
return new Proxy(target, {
get(obj, key, receiver) {
const result = Reflect.get(obj, key, receiver)
track(obj, key)
// Lazy nested proxying: wrap objects only when actually read
return typeof result === 'object' && result !== null ? reactive(result) : result
},
set(obj, key, value, receiver) {
const oldValue = (obj as any)[key]
const result = Reflect.set(obj, key, value, receiver)
if (oldValue !== value) trigger(obj, key)
return result
}
})
}Why targetMap Uses a WeakMap
- Keys are the original (raw) target objects, not the Proxy wrappers — a WeakMap allows the target to be garbage collected once no other strong references exist, without leaking the entire dependency graph forever
- A regular Map would keep every observed object alive for the lifetime of the app, since Map holds strong references to its keys
- This is the actual root of Vue's automatic memory safety around reactive objects — no manual 'unobserve' call is ever required when a reactive object is dropped
ref vs reactive: The Core Trade-off
ref wraps a value in an object with a single reactive property (.value), using get/set accessors rather than a Proxy — necessary because JavaScript cannot intercept reassignment of a primitive binding itself, only property access on an object. reactive wraps an object directly in a Proxy, with no .value indirection, but cannot track primitives and cannot be reassigned without losing reactivity.
| Criteria | ref | reactive |
|---|---|---|
| Works with primitives | Yes — this is its primary purpose | No — Proxy requires an object target |
Access syntax in <script> | Requires .value | Direct property access, no .value |
Access syntax in <template> | Auto-unwrapped — no .value needed | Direct property access |
| Reassignment | Safe — myRef.value = newObj retains reactivity | Unsafe — myState = newObj breaks reactivity; must mutate properties in place or use Object.assign(myState, newObj) |
| Destructuring | Safe — each destructured ref stays reactive since it's an independent object with its own getter/setter | Unsafe — destructured primitives lose their reactive binding entirely; use toRefs() first |
| Underlying mechanism | Class instance with get value()/set value() accessors | Proxy with get/set/deleteProperty/has traps |
| Best use case | Primitives, or any value that might be reassigned wholesale (API responses, nullable state) | Cohesive object state that is only ever mutated in place, never replaced |
import { ref, reactive } from 'vue'
const count = ref(0)
// Case 1: ref as a property of reactive — auto-unwrapped
const state = reactive({ count })
console.log(state.count) // 0, NOT a ref object — no .value needed
state.count = 5 // Updates count.value under the hood
// Case 2: ref inside a reactive array — NOT auto-unwrapped
const list = reactive([count])
console.log(list[0]) // the ref object itself
console.log(list[0].value) // 5 — .value still required herecomputed: Lazy, Cached Derivation
A computed is a special ReactiveEffect with two additional properties: it is lazy (the getter does not run until first read) and it caches its result, only re-running when one of its tracked dependencies triggers — not on every render.
import { ref, computed } from 'vue'
const items = ref<{ price: number; qty: number }[]>([
{ price: 10, qty: 2 },
{ price: 25, qty: 1 }
])
// Read-only computed: recomputes ONLY when items.value changes,
// not on every component render — this is the entire performance point of computed
// over calling a plain function directly in the template.
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0))
// Writable computed: useful for a two-way bound derived value (e.g. a formatted input)
const totalWithTax = computed({
get: () => total.value * 1.08,
set: (newTotal) => {
// Distribute the change back proportionally — rare, but demonstrates the setter contract
const ratio = newTotal / 1.08 / total.value
items.value.forEach((i) => (i.qty = Math.round(i.qty * ratio)))
}
})watch vs watchEffect
watchEffect auto-tracks whatever reactive dependencies its callback reads on each run, starts eagerly, and gives no access to previous values. watch requires an explicit source, is lazy by default, and provides both old and new values — trading auto-tracking convenience for precision.
| Criteria | watchEffect | watch |
|---|---|---|
| Dependency declaration | Implicit — tracks anything read during execution | Explicit — a getter, ref, or array of sources |
| Execution timing | Immediate — runs once synchronously on creation, then on every dependency change | Lazy by default — only runs on change, unless { immediate: true } |
| Access to old value | No | Yes — (newVal, oldVal) => {} |
| Risk of over-tracking | Higher — any reactive read anywhere in the callback becomes a dependency, including ones you didn't intend | Lower — only the explicit source is tracked |
| Best use case | Side effects whose dependencies are naturally derived from the effect body itself (e.g. syncing a DOM attribute to several refs read inline) | Reacting to one specific, named piece of state where you need the previous value or fine control over timing |
import { ref, watch } from 'vue'
const searchQuery = ref('')
// flush: 'post' ensures the callback runs AFTER the DOM has updated to reflect
// the change that triggered it — necessary when the callback reads $refs / DOM state.
// Default flush: 'pre' runs before DOM update, in the same microtask queue as component updates.
watch(searchQuery, async (newQuery, oldQuery) => {
if (newQuery === oldQuery) return
// debounce/cancel logic would live here
}, { flush: 'post' })Practical Example: Real-Time Telemetry Dashboard
A dashboard consuming a high-frequency mock data stream (simulating WebSocket ticks at ~20Hz) is a realistic stress test for reactivity performance boundaries: naive reactive arrays that grow unbounded, or watch callbacks that re-run expensive work on every tick, will visibly jank. The example below establishes explicit boundaries — a capped ring buffer and a shallowRef for the hot path.
import { shallowRef, triggerRef, onScopeDispose } from 'vue'
export interface TelemetryTick {
timestamp: number
cpuLoad: number
memoryMb: number
requestsPerSec: number
}
const MAX_BUFFER_SIZE = 120 // 6 seconds of history at 20Hz
export function useTelemetryStream(intervalMs = 50) {
// shallowRef: only the .value reassignment itself is tracked, NOT deep
// mutations of the array's contents. This is deliberate — the buffer is
// replaced wholesale on each tick (see pushTick), so deep reactivity on
// every element would track far more than any consumer needs, at real cost
// on a 20Hz stream.
const buffer = shallowRef<TelemetryTick[]>([])
function pushTick(tick: TelemetryTick): void {
const next = buffer.value.length >= MAX_BUFFER_SIZE
? [...buffer.value.slice(1), tick]
: [...buffer.value, tick]
buffer.value = next
// triggerRef is unnecessary here since reassigning .value already triggers —
// it would only be needed if we mutated buffer.value in place (e.g. .push()),
// which shallowRef would NOT detect on its own.
}
function generateMockTick(): TelemetryTick {
return {
timestamp: Date.now(),
cpuLoad: Math.min(100, Math.max(0, 40 + Math.sin(Date.now() / 1000) * 30 + Math.random() * 10)),
memoryMb: 512 + Math.random() * 128,
requestsPerSec: Math.round(200 + Math.random() * 50)
}
}
const intervalId = setInterval(() => pushTick(generateMockTick()), intervalMs)
// onScopeDispose fires when the effect scope that called this composable is torn
// down (component unmount, or a manually created effectScope) — the cleanup runs
// regardless of whether the caller is a component at all, unlike onUnmounted.
onScopeDispose(() => clearInterval(intervalId))
return { buffer }
}<script setup lang="ts">
import { computed } from 'vue'
import { useTelemetryStream } from './useTelemetryStream'
const { buffer } = useTelemetryStream(50)
// Derived from a shallowRef reassignment, so this recomputes exactly once per
// tick — not once per property read, since the whole array reference changes.
const latest = computed(() => buffer.value.at(-1))
const avgCpuLoad = computed(() => {
if (buffer.value.length === 0) return 0
const sum = buffer.value.reduce((acc, t) => acc + t.cpuLoad, 0)
return Math.round((sum / buffer.value.length) * 10) / 10
})
</script>
<template>
<section class="telemetry">
<header class="telemetry__header">
<h2>Live Telemetry</h2>
<span class="telemetry__badge" :class="{ 'telemetry__badge--hot': (latest?.cpuLoad ?? 0) > 80 }">
CPU {{ latest?.cpuLoad.toFixed(1) ?? '—' }}%
</span>
</header>
<dl class="telemetry__stats">
<div class="telemetry__stat">
<dt>Avg CPU (window)</dt>
<dd>{{ avgCpuLoad }}%</dd>
</div>
<div class="telemetry__stat">
<dt>Memory</dt>
<dd>{{ latest?.memoryMb.toFixed(0) ?? '—' }} MB</dd>
</div>
<div class="telemetry__stat">
<dt>Requests/sec</dt>
<dd>{{ latest?.requestsPerSec ?? '—' }}</dd>
</div>
</dl>
<svg class="telemetry__sparkline" viewBox="0 0 240 60" preserveAspectRatio="none">
<polyline
:points="buffer.map((t, i) => `${i * 2},${60 - t.cpuLoad * 0.6}`).join(' ')"
fill="none"
stroke="#16a34a"
stroke-width="2"
/>
</svg>
</section>
</template>
<style scoped>
.telemetry {
font-family: system-ui, sans-serif;
max-width: 480px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.25rem;
}
.telemetry__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.telemetry__badge {
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: #dcfce7;
color: #166534;
font-size: 0.85rem;
font-weight: 600;
}
.telemetry__badge--hot {
background: #fee2e2;
color: #991b1b;
}
.telemetry__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin: 0 0 1rem;
}
.telemetry__stat dt {
font-size: 0.75rem;
color: #666;
}
.telemetry__stat dd {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.telemetry__sparkline {
width: 100%;
height: 60px;
background: #fafafa;
border-radius: 4px;
}
</style>Module 2 Checkpoint
Q1. Why can Vue 3's Proxy-based reactivity detect new property additions that Vue 2 could not?
Q2. In the telemetry dashboard example, why is `shallowRef` used for the ring buffer instead of `ref` or `reactive`?