How Vue 3 Rebuilt Reactivity Around Proxy
Vue 3 replaced Object.defineProperty with ES2015 Proxy as the foundation of its reactivity system. The change resolves longstanding limitations around property addition, deletion, and array mutation, and introduces a cleaner API surface through reactive() and ref().
Vue 2’s reactivity system had a narrow but well-known set of failure cases. Property additions were invisible to the framework. Array index writes did not trigger updates. Deletion was undetectable. These were not bugs in Vue’s implementation but direct consequences of the API Vue had to use. Vue 3 replaced that foundation entirely, moving from Object.defineProperty to ES2015 Proxy. The change is not cosmetic: it removes the entire class of limitations and reshapes how reactive state is declared.
Why Object.defineProperty Could Not Go Further
Object.defineProperty allows you to install a getter and setter on a specific, named property of an object. Vue 2 used this at instance creation time: it walked the data object, found every property that existed at that moment, and rewrote each one with a getter that tracked dependents and a setter that notified them. The mechanism worked, but it was bounded by a hard constraint: the property had to exist before the getter and setter could be installed.
A property added after instance creation had no getter or setter. Vue had no interception point for it, so reads and writes were invisible to the reactivity system. The same applied to deletion: removing a property with delete bypassed the setter entirely, and Vue received no notification. For arrays, directly assigning to an index such as this.items[2] = 'value' wrote to a dynamically allocated slot that Vue had never instrumented, and setting this.items.length = 0 bypassed property access entirely. Vue 2 worked around this by wrapping the seven mutating array methods and requiring Vue.set for property additions, but these were compensations for a structural limitation, not solutions to it.
How Proxy Changes the Interception Model
A Proxy wraps an entire object rather than individual properties. It intercepts operations at the object level through a set of trap functions. A get trap fires for any property access on the proxy, whether the property existed when the proxy was created or was added afterward. A set trap fires for any property assignment, including new keys. A deleteProperty trap fires for any deletion. This is the core difference: Object.defineProperty requires knowing which property to instrument before the operation happens, whereas a Proxy intercept every operation against the object, regardless of what key is involved.
Vue 3’s internal reactive() function returns a Proxy with get and set traps. The get trap calls track to record the current effect as a dependent of that property. The set trap performs the assignment and then calls trigger to re-run all effects that depend on that key.
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)
}
})
}
Because the get and set traps receive the key as a parameter at the time of the operation, they handle new keys just as naturally as existing ones. Vue 3 does not need Vue.set. Property additions, deletions via the deleteProperty trap, and direct array index assignments are all intercepted and tracked without any special workaround.
What Vue 3 Can Now Detect
The consequence of moving to Proxy is a substantially expanded set of operations that trigger reactivity. Adding a new property to a reactive object updates any computed values or template expressions that accessed that object. Deleting a property with delete triggers the same. Writing to an array by index works correctly, and so does assigning to length.
import { reactive } from 'vue'
const user = reactive({ name: 'Ada' })
// Vue 3: triggers reactivity — no Vue.set required
user.age = 30
// Vue 3: triggers reactivity — deletion is observable
delete user.name
const items = reactive(['alpha', 'beta', 'gamma'])
// Vue 3: triggers reactivity — direct index assignment works
items[0] = 'replaced'
// Vue 3: triggers reactivity — length assignment works
items.length = 1
None of these patterns worked in Vue 2 without a workaround. In Vue 3 they are ordinary JavaScript, and the reactivity system observes all of them.
reactive() and ref(): Two APIs for Different Shapes of State
Vue 3 exposes two primary APIs for declaring reactive state. reactive() accepts an object and returns a Proxy of it. ref() accepts any value, including primitives, and returns a wrapper object with a single .value property. The distinction exists because Proxy operates on objects. A primitive cannot be wrapped in a Proxy directly, so ref uses a getter and setter on the .value property of a plain object to achieve the same tracking behaviour.
import { reactive, ref } from 'vue'
// reactive() — for objects; returns a Proxy
const state = reactive({ count: 0 })
state.count++ // reactive read and write
// ref() — for any value, including primitives
const count = ref(0)
count.value++ // reactive read and write through .value
reactive() has three documented limitations. It cannot hold primitives. It loses its reactivity connection if the reference is replaced entirely, because the tracked object is the original Proxy and the new object is a different identity. It also loses reactivity when its properties are destructured into local variables, because those variables are plain copies that no longer route through the Proxy.
const state = reactive({ count: 0 })
// Reactivity is lost — count is now a plain number
let { count } = state
count++ // does not affect state.count
// Reactivity is also lost — the new object is not the tracked Proxy
state = reactive({ count: 1 }) // breaks the original reactive reference
ref() avoids these problems. A ref is itself an object, so passing it to a function or reassigning its .value keeps the reactive wrapper intact. Inside templates, Vue unwraps refs automatically so .value is not needed in template expressions. The Vue documentation recommends ref() as the primary API for declaring reactive state, using reactive() when managing complex nested objects where full replacement is not needed.
Vue’s Reactivity as a Standalone Layer
Vue 3 was designed with a deliberate separation between the reactivity system and the rendering engine. The reactivity primitives, ref, reactive, computed, watchEffect, and related utilities, are published as the @vue/reactivity package independently of the rendering layer. This means the same tracking and dependency notification system can be used in a Node.js script, a CLI tool, or any JavaScript environment without importing the component model or the virtual DOM.
This separation was not possible in Vue 2, where reactivity was wired directly into the component instance and the Observer, Dep, and Watcher classes were internal implementation details. In Vue 3 the system is a first-class public API that the framework consumes in the same way any external code would.
What This Means When Migrating from Vue 2
The most immediate practical change when moving from Vue 2 to Vue 3 is that Vue.set and this.$set are gone. They are not needed. Code that used them to add properties reactively can replace those calls with direct property assignment on a reactive object. Workarounds for array index writes can be removed; direct assignment now works.
The second change is the introduction of ref(). Vue 2 components declared all state in data(), and every declared property was automatically reactive. Vue 3 composables declare state explicitly with ref() or reactive(), and the choice between them is meaningful rather than automatic. For most state, ref() is the correct default. reactive() is appropriate when you are managing a structured object and do not need to replace the entire reference or destructure its properties without toRefs.
The third change is the conceptual shift from Options API, where reactivity was implicit in data, to the Composition API, where reactivity is explicit in the primitives each function uses. The underlying mechanism, intercepting reads to track dependencies and intercepting writes to trigger updates, is the same. The surface it operates on and the scope of what it can observe have changed substantially.
What You Can Do Now
Confirm the behavioural differences directly by running Vue 3 in an environment that also allows you to compare Vue 2 patterns.
import { reactive, ref, watchEffect } from 'vue'
// --- reactive(): object-level Proxy ---
const user = reactive({ name: 'Ada' })
watchEffect(() => {
// Accessing user.name and user.age registers both as dependencies
console.log(user.name, user.age)
})
// Both assignments trigger the effect above
user.name = 'Grace'
user.age = 30 // New property — reactive in Vue 3, invisible in Vue 2
delete user.name // Deletion — reactive in Vue 3, invisible in Vue 2
// --- ref(): wrapper for any value, including primitives ---
const count = ref(0)
watchEffect(() => {
console.log(count.value)
})
count.value = 1 // triggers the effect
// --- Destructuring with toRefs preserves reactivity ---
import { toRefs } from 'vue'
const state = reactive({ x: 0, y: 0 })
const { x, y } = toRefs(state) // x and y are refs, not plain values
x.value = 10 // still reactive — updates state.x
// --- Array index assignment ---
const items = reactive(['alpha', 'beta', 'gamma'])
watchEffect(() => {
console.log(items[0])
})
items[0] = 'replaced' // triggers the effect — works without splice
The watchEffect calls above make the dependency tracking visible. Each one re-runs whenever any reactive value it reads changes. Observe that adding user.age, deleting user.name, and assigning items[0] each trigger the corresponding effect, none of which would have been detectable in Vue 2 without explicit workarounds.