Vue 3 Reactivity: Choosing ref() vs reactive() Without the Guesswork
Destructuring a reactive() object can silently freeze your UI. Learn when to use ref() vs reactive() in Vue 3, how template unwrapping works, and how to keep state predictable.
06 Oct 2025, 03:34 UTC

The silent UI freeze
You write a clean component: a reactive() object holds form state, you destructure a field into a local variable, and the template renders it. Everything looks fine until you type into an input and nothing updates. No error, no warning—just a stale view. This is one of the most common Vue 3 reactivity bugs, and it usually traces back to picking the wrong primitive or breaking a proxy chain.
Two primitives, two data shapes
Vue 3 gives you two ways to declare reactive state, and they are not interchangeable.
ref() wraps any value—primitives included—inside an object with a .value property. Vue uses a getter/setter pair so that reading or writing .value triggers dependency tracking. Because the wrapper is a stable object reference, you can freely reassign it: count.value = 10 or even count.value = { deep: true } without losing reactivity.
reactive() takes a different path. It returns a JavaScript Proxy that intercepts property access on the original object. This works well for nested structures, but it comes with two hard limits: it only accepts objects and arrays, and reassigning the variable itself—state = { ... }—severs the proxy connection. The old object was reactive; the new plain object is not.
The destructuring trap
Proxies only track access through the proxy itself. The moment you pull a property out, you are holding a plain value, not a tracked reference.
Consider a simple component:
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({
firstName: 'Ada',
lastName: 'Lovelace'
})
// Broken: plain string, no longer tracked
const { firstName } = state
</script>
<template>
<input v-model="firstName">
</template>
Typing in that input updates the local firstName variable, but the template never re-renders because Vue's dependency tracker lost the link. The fix is toRefs(), which converts each property into a ref that still points back to the source object:
const { firstName } = toRefs(state)
Now firstName is a ref. In the template it unwraps automatically, and in script you read or write firstName.value. The connection stays intact.
What templates actually see
Vue's template compiler unwraps refs at the top level of the render context. If you declare const count = ref(0), the template uses {{ count }}, not {{ count.value }}. This convenience disappears one level down: a ref nested inside a reactive() object does not unwrap in the template, and neither does a ref passed as a prop unless the parent explicitly unwraps it.
That asymmetry is why Vue's style guide leans toward ref() for independent primitives and reactive() for cohesive, deeply nested objects. Mixing them without understanding the unwrap boundary creates exactly the kind of silent failure described above.
Choosing under real constraints
The decision usually comes down to lifecycle and shape.
| Scenario | Recommended | Reason |
|---|---|---|
| Independent primitive (string, number, boolean) | ref() | Primitives cannot be proxied; reactive() rejects them. |
| Value you expect to replace wholesale | ref() | Reassigning a reactive() variable breaks the proxy. |
| Deeply nested object mutated in many places | reactive() | Proxy tracks deep access without manual .value noise. |
| State passed to composables or child components | ref() or toRefs() | Destructuring a reactive object loses reactivity. |
There are edge cases worth flagging. If you use Map or Set inside reactive(), Vue 3 provides reactive collection handling, but the behavior differs from plain objects and has shifted across minor 3.x releases—verify against your installed version. Also, toRefs() creates refs that share reactivity with the source; if you mutate the original object after conversion, the refs reflect those changes, which can surprise developers who expect a snapshot.
A practical check
Before shipping, open Vue Devtools and inspect the component's reactive state. If you destructured a reactive() object, confirm the template still updates when the source changes. If it does not, convert the destructured properties with toRefs() or access them directly through the reactive object. For a quick sanity test, log the value in a watcher or computed and compare it against the DOM after a mutation.
Reactivity in Vue 3 is predictable once you respect the boundaries: proxies for object graphs, wrapper refs for values you swap, and toRefs() whenever you need to pull a reactive property into a local scope. Match the tool to the data, and the silent freezes disappear.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.