AcademyVue
Ref vs Reactive: Vue's State Primitives
ref wraps any value behind a .value getter/setter; reactive proxies an object directly. The trade-offs explain why most Vue code reaches for ref by default.
2 min readUpdated 3 exercises
ref wraps a value; reactive proxies an object
ref(initialValue) returns an object with a single .value property. Reading or writing .value is what Vue's reactivity system tracks — the wrapper exists because JavaScript has no way to intercept reads and writes of a primitive like a number or a string directly:
import { ref } from 'vue'
const count = ref(0)
count.value++ // triggers reactivity
console.log(count.value) // 1reactive(initialObject) instead returns a Proxy wrapping the object itself, so properties are read and written directly, no .value:
import { reactive } from 'vue'
const state = reactive({ count: 0 })
state.count++ // triggers reactivity
console.log(state.count) // 1Inside a <template>, Vue automatically unwraps a top-level ref — {{ count }}, not {{ count.value }} — which is why the .value requirement is easy to forget until you hit it in a plain <script> block or a composable.
reactive's proxy comes with two sharp edges
The first: destructuring a reactive object breaks the connection to it, because a plain variable assignment copies the value out from under the proxy and the copy is not reactive:
const state = reactive({ count: 0 })
const { count } = state // count is now a plain, frozen number — no longer trackedtoRefs(state) exists specifically to destructure safely — it converts each property into its own ref, preserving the reactive connection through the unwrap.
The second: reactive cannot wrap a primitive at all — reactive(0) is a runtime warning, because there is nothing to proxy — and reassigning a reactive variable to a whole new object breaks reactivity the same way destructuring does, since the new object was never wrapped:
let state = reactive({ count: 0 })
state = { count: 5 } // plain object now — nothing is tracking itref has neither problem: reassigning .value to something entirely different, including another object, keeps working, because the reactivity lives on the stable wrapper, not on whatever it currently points at.
Why most real Vue code defaults to ref
ref works uniformly on primitives and objects, survives destructuring at the call site (const { data } = useFetch() is fine — data is a ref, still connected), and is what every official composable returns for exactly that reason. reactive still earns its place for a self-contained bundle of related fields you will only ever access as a group and never destructure — a form's values/errors/touched, say — where the direct-property-access syntax reads better than .value on every field. When in doubt, ref is the safer default precisely because it has none of reactive's destructuring or reassignment traps.
What interviewers check
Whether you can explain the destructuring trap without being prompted — it is the single most common Composition API bug, and it is also the fastest way to tell whether someone has actually shipped Vue 3 code or only read about it.
Now practice it
2 quizzes · 1 drillReading this page is the cheap half. These are the exercises that make you use it.
- ref vs reactiveFix exactly the destructuring trap this page opens on — a reactive() property read into a frozen local variable.DrillSolve · EasyDrillSolve · Easy
- Composition API · watch & Composableswatch and composables both live or die on getting ref vs reactive right first — this set assumes that.QuizTake · MediumQuizTake · Medium
- Composition API · Advanced ComposablesComposables built on the wrong primitive break in ways that only show up under harder scenarios like these.QuizTake · HardQuizTake · Hard
Read next
Composables: Reusable Stateful Logic
A composable is a plain function using ref, computed and watch — Vue's answer to a custom React hook, without the rules-of-hooks constraints.
3 min
Conditional Rendering and v-for Lists
v-if versus v-show is a cost trade-off, not a style choice, and v-for needs a stable :key for the same reason React needs one on map().
3 min