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.

CriteriaVue 2 (Object.defineProperty)Vue 3 (Proxy)
New property additionInvisible to reactivity — Vue.set() requiredFully reactive — Proxy's set trap fires for any key, existing or new
Array index assignmentarr[i] = x bypasses reactivity (no getter/setter installed per index)Fully reactive — Proxy's set trap intercepts index writes directly
Delete detectiondelete obj.key invisible — Vue.delete() requiredFully reactive via the deleteProperty trap
Initialization costWalks and wraps every property recursively upfront, even ones never readLazy — nested objects are only wrapped in a Proxy when actually accessed (see below)
Map/Set supportNot reactive without manual instrumentationNatively 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.

reactivity-internals-simplified.tstypescript
// 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.

Criteriarefreactive
Works with primitivesYes — this is its primary purposeNo — Proxy requires an object target
Access syntax in <script>Requires .valueDirect property access, no .value
Access syntax in <template>Auto-unwrapped — no .value neededDirect property access
ReassignmentSafe — myRef.value = newObj retains reactivityUnsafe — myState = newObj breaks reactivity; must mutate properties in place or use Object.assign(myState, newObj)
DestructuringSafe — each destructured ref stays reactive since it's an independent object with its own getter/setterUnsafe — destructured primitives lose their reactive binding entirely; use toRefs() first
Underlying mechanismClass instance with get value()/set value() accessorsProxy with get/set/deleteProperty/has traps
Best use casePrimitives, or any value that might be reassigned wholesale (API responses, nullable state)Cohesive object state that is only ever mutated in place, never replaced
ref-unwrapping-edge-case.tstypescript
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 here

computed: 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.

computed-usage.tstypescript
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.

CriteriawatchEffectwatch
Dependency declarationImplicit — tracks anything read during executionExplicit — a getter, ref, or array of sources
Execution timingImmediate — runs once synchronously on creation, then on every dependency changeLazy by default — only runs on change, unless { immediate: true }
Access to old valueNoYes — (newVal, oldVal) => {}
Risk of over-trackingHigher — any reactive read anywhere in the callback becomes a dependency, including ones you didn't intendLower — only the explicit source is tracked
Best use caseSide 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
watch-timing.tstypescript
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.

useTelemetryStream.tstypescript
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 }
}
TelemetryDashboard.vuevue
<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

  1. Q1. Why can Vue 3's Proxy-based reactivity detect new property additions that Vue 2 could not?

  2. Q2. In the telemetry dashboard example, why is `shallowRef` used for the ring buffer instead of `ref` or `reactive`?