codejump

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

Waiting at the end of this page.

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:

JavaScript
import { ref } from 'vue'

const count = ref(0)
count.value++        // triggers reactivity
console.log(count.value)   // 1

reactive(initialObject) instead returns a Proxy wrapping the object itself, so properties are read and written directly, no .value:

JavaScript
import { reactive } from 'vue'

const state = reactive({ count: 0 })
state.count++         // triggers reactivity
console.log(state.count)   // 1

Inside 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:

JavaScript
const state = reactive({ count: 0 })
const { count } = state   // count is now a plain, frozen number — no longer tracked

toRefs(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:

JavaScript
let state = reactive({ count: 0 })
state = { count: 5 }   // plain object now — nothing is tracking it

ref 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 drill

Reading this page is the cheap half. These are the exercises that make you use it.