VuePublished 1/31/2024

Vue 3 Reactivity: Why Proxy Replaced Object.defineProperty

Starting from the built-in limits of Vue 2's Object.defineProperty, this post breaks down why Vue 3 rewrote its reactivity system around Proxy, and exactly which long-standing pain points that change fixed.

Every Vue 2 developer has hit this

If you've written Vue 2, you've probably run into this: add a new property to an object in data, and the view stubbornly refuses to update. Set an array item with arr[0] = newValue, and nothing happens either. Your only options were this.$set(obj, key, value), or working around it with arr.splice(0, 1, newValue).

That "the data changed but the view didn't" behavior isn't a bug — it's a design limitation of Vue 2's reactivity system. In Vue 3, all of it just disappears: adding a property, deleting a property, changing an array index — all of it triggers updates automatically, with no special APIs to remember.

Behind that change is a completely different mechanism under the hood: Object.defineProperty swapped out for Proxy. Understanding the difference between the two isn't just memorizing "Vue 3 uses Proxy" — it's understanding exactly where those Vue 2 quirks came from in the first place.

Vue 2's foundation: Object.defineProperty

Let's start with how Vue 2 does reactivity. The core tool is Object.defineProperty, which lets you redefine a getter and setter for a property on an object:

js
function defineReactive(obj, key, value) {
  Object.defineProperty(obj, key, {
    get() {
      console.log(`reading ${key}`)
      // dependency tracking happens here
      return value
    },
    set(newValue) {
      if (newValue === value) return
      console.log(`setting ${key}`)
      value = newValue
      // triggering a view update happens here
    },
  })
}
 
const data = { count: 0 }
defineReactive(data, 'count', data.count)
 
data.count // reading count
data.count = 1 // setting count

The idea: quietly swap out a plain property for a getter/setter pair. Every read logs "this property was used here" (dependency tracking); every write notifies "anything using this property needs to update" (triggering updates). When Vue 2 initializes, it walks the entire data object recursively, layer by layer, property by property, calling defineReactive on each one — converting the whole object tree into this getter/setter form.

Think of it like wiring up security cameras in every room of a house. A worker has to go room by room to install them. Rooms that get wired up are covered; rooms nobody's gotten to yet — or rooms built after the crew has already left — are blind spots.

The problem is "one room at a time"

Object.defineProperty only works on a single property that already exists. That one fact is the root of Vue 2's three built-in limitations.

Limitation 1: new properties go undetected

js
const vm = new Vue({
  data: { user: { name: 'John' } },
})
 
vm.user.age = 18 // the view won't update

age didn't exist at initialization time, so defineReactive never got the chance to wire up a getter/setter for it. That's exactly why Vue 2 needs Vue.set(vm.user, 'age', 18) — it gives Vue a manual hook to wire up monitoring for the new property.

Limitation 2: deleted properties go undetected

Same story: delete vm.user.name doesn't trigger an update either, because delete never passes through any getter or setter. Vue 2 has no idea it happened, so you're stuck patching it with Vue.delete.

Limitation 3: array index assignment and length changes go undetected

js
const vm = new Vue({ data: { list: [1, 2, 3] } })
 
vm.list[0] = 100 // the view won't update
vm.list.length = 0 // the view won't update

In theory, defineReactive could handle every array index too, but array length can change at any time — watching every index individually is too expensive and can't keep up with new ones being added. So Vue 2 gave up on tracking index access entirely and instead intercepted the array's mutation methods: it overrode all seven of push, pop, shift, splice, and the rest, so calling them triggers an update as a side effect. Assigning directly by index or reassigning length skips those overridden methods entirely, so naturally, nothing gets detected.

The extra cost: recursive initialization

Beyond these three feature gaps, Object.defineProperty carries a performance cost too: since it has to walk the entire object tree at initialization and convert every property into a getter/setter, the deeper and larger the data structure, the heavier that startup cost gets — even for a nested property that never ends up being used, it still gets "wired up" along the way.

Vue 3's fix: Proxy watches the whole object, not one property at a time

Proxy takes a completely different approach. Instead of rewriting every property on an object, it wraps the object in a layer that intercepts every operation performed on it — reads (get), writes (set), deletes (deleteProperty), existence checks (has), key enumeration (ownKeys), and more:

js
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      console.log(`reading ${String(key)}`)
      const result = Reflect.get(target, key, receiver)
      // dependency tracking happens here
      return typeof result === 'object' && result !== null
        ? reactive(result) // wrap nested objects lazily, only when accessed
        : result
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      console.log(`setting ${String(key)}`)
      // triggering an update happens here — covers both new and existing properties
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      console.log(`deleting ${String(key)}`)
      // triggering an update happens here
      return result
    },
  })
}
 
const state = reactive({ user: { name: 'John' } })
 
state.user.age = 18 // setting age, update triggers normally
delete state.user.name // deleting name, update triggers normally

Back to the house analogy: Proxy isn't a camera in every room — it's a single system at the front door that monitors everything going in and out of the whole house. Whether it's activity in an existing room, someone building a new one, or tearing one down, none of it slips past that system. It doesn't need to know the house's layout in advance.

That one property directly solves all three of Vue 2's limitations:

  • Adding a property: state.user.age = 18 triggers set, regardless of whether age existed before — so Vue.set can retire.
  • Deleting a property: delete state.user.name triggers deleteProperty, no more need for Vue.delete.
  • Array operations: state.list[0] = 100 and state.list.length = 0 are both set operations on the list object, and Proxy catches those too — no need to override any of the array's seven methods.

Another key change: recursive to lazy

Look closely at the get trap in the reactive function above: it only calls reactive(result) to wrap the next layer when a property is actually read and its value happens to be an object. In other words, Vue 3 doesn't convert the entire object tree upfront at initialization — it converts only the layer you actually touch, when you touch it. That's lazy conversion. Compared to Vue 2's fully recursive conversion at init time, Vue 3's on-demand approach cuts out a meaningful chunk of unnecessary startup overhead.

Why pair it with Reflect?

Notice that every trap in the example above uses Reflect.get and Reflect.set instead of writing target[key] and target[key] = value directly. That's not an arbitrary stylistic choice — it's there to get this binding right.

When an object has a getter that references this internally, the third argument to Reflect.get(target, key, receiver) — receiver — makes sure that this inside the getter points to the outer Proxy, not the raw target. That way, if the getter reads other properties off this, those reads still get intercepted by the Proxy and correctly tracked as dependencies, instead of silently slipping past the reactivity system because this pointed at the wrong object. It's an easy detail to overlook when pairing Proxy with Reflect, but it directly affects correctness.

The trade-off: not every environment supports it

Proxy isn't free of cost either. It's native ES6 syntax, and it cannot be polyfilled for older environments — because what Proxy intercepts is engine-level behavior on objects, and that kind of interception can't be simulated in plain JavaScript. That's exactly why Vue 3 dropped IE11 support entirely, while Vue 2 could support IE all the way back to version 9 — precisely because Object.defineProperty is older, more widely-supported syntax. This trade-off is Vue 3 trading "no old-browser support" for "a reactivity system with no blind spots."

Wrapping up

Vue 2 used Object.defineProperty to rewrite existing properties one at a time, a mechanism that can never reach operations it doesn't know about in advance. Adding properties, deleting properties, and array index assignment all became blind spots as a result, patched over with Vue.set, Vue.delete, and rewritten array methods. On top of that, recursively walking the entire object tree at init time meant the larger the data, the heavier the startup cost.

Switching to Proxy, Vue 3 upgraded what gets watched from "a single property" to "every operation on the whole object" — additions, deletions, and array changes are all caught natively, with no special APIs needed. Paired with lazy conversion, there's no more need to do all that recursive work upfront at init. The only real cost is giving up support for older browsers, IE11 in particular. Once you understand this underlying logic, Vue 3's reactivity behavior stops being "because the docs say so" and becomes something you can derive directly from how Proxy's interception actually works.