Skip to main content

Ref vs reactive in Vue.js

ref vs reactive in Vue.js: ref wraps any value in a reactive container you access via .value; reactive turns an object into a deep proxy with direct property access and no wrapper.

Theory

TL;DR

  • ref = a box around your value; access it via .value in script, auto-unwrapped in templates
  • reactive = a live proxy of an object; access properties directly, no wrapper at all
  • ref accepts primitives and objects; reactive only works with objects (throws on primitives)
  • Need to replace the whole value? Only ref lets you do that safely
  • Not sure which to pick? The Vue team now recommends ref as the default

Quick example

vue
<script setup> import { ref, reactive } from 'vue' const count = ref(0) // primitive: needs .value in script count.value++ // count.value is now 1 const state = reactive({ count: 0, user: 'Alice' }) // object: direct access state.count++ // state.count is now 1 </script> <template> <p>{{ count }}</p> <!-- auto-unwrapped: shows 1, no .value --> <p>{{ state.count }}</p> <!-- direct access: shows 1 --> </template>

Vue unwraps ref in templates automatically. You write {{ count }}, not {{ count.value }}. In script, you always need .value.

Key difference

ref adds a thin wrapper around any value. Primitives become reactive through that wrapper, objects get a proxy inside it. The wrapper gives you a stable reference you can reassign. reactive skips the wrapper entirely, giving you cleaner dot-notation on objects, but you cannot swap the whole object out. Reassign a reactive variable and the proxy is gone. The template goes stale.

When to use

  • Counter, boolean, stringref. Straightforward and reassignable.
  • Form object with nested fieldsreactive if you never replace the whole form; ref if you reset it with a fresh object.
  • API responseref. The value might be null before the request resolves, then an object or array.
  • Array of itemsref. You can replace the whole array or push into it.
  • Unsure? Many teams use ref for everything and reach for reactive only when dot-notation genuinely simplifies the code.

Comparison table

Featurerefreactive
Input typesPrimitive or objectObject / array only
Script access.value requiredDirect property access
Template accessAuto-unwrappedDirect property access
Full reassignmentYes (ref.value = newObj)No (breaks the proxy)
PrimitivesYes (ref(42))No (throws error)
DestructuringSafe (ref stays reactive)Loses reactivity; use toRefs
TypeScript typeRef<T>Original type
Reach for it whenCounters, dynamic values, any primitiveFixed-shape state, forms with no full reset

How Vue handles this internally

Both ref and reactive are built on ES6 Proxy. reactive wraps the object in a Proxy directly, trapping get and set at every nested level. ref creates a small wrapper object with get/set on .value, which then plugs into the same proxy system. When you write {{ count }} in a template, Vue's compiler calls unref(count): if the value has a __v_isRef flag, it reads .value; otherwise it returns the value as-is.

Common mistakes

Using reactive on a primitive:

js
const count = reactive(0) // Error: reactive() must be called on an object

Fix: const count = ref(0).

Forgetting .value in script:

js
const count = ref(0) count++ // Wrong: mutates the wrapper reference, not the inner value // count.value stays 0

Fix: count.value++ or ++count.value.

Reassigning a reactive variable:

js
let state = reactive({ count: 0 }) state = { count: 10 } // Proxy is gone. Template no longer updates.

Fix: mutate properties directly (state.count = 10) or switch to ref.

Destructuring reactive without toRefs:

js
const state = reactive({ name: 'Alice', age: 25 }) let { name } = state // name is now a plain string, not reactive name = 'Bob' // template does not update

Fix: const { name } = toRefs(state), then name.value = 'Bob'.

Real-world usage

  • Pinia stores: often use reactive for the root state object (fixed shape, direct mutations)
  • Nuxt useFetch: returns data as a ref because the value starts as null and resolves later
  • VueUse composables: almost all use ref internally (useStorage, useLocalStorage, and most others)
  • VeeValidate / FormKit: reactive for form objects with nested field state
  • Component state: in most codebases I have seen, teams that start mixing both end up standardizing on ref after a few months - it handles every case without the reassignment trap

Follow-up questions

Q: Why does reassigning a reactive variable break reactivity?
A: Because reactive() returns a Proxy tied to the original object. When you write state = {}, you drop the reference to that Proxy. Vue's dependency tracker still points at the old proxy, so no updates fire.

Q: How does the template unwrap ref automatically?
A: Vue's template compiler wraps top-level refs with unref() in the generated render function. If the value carries a __v_isRef flag, it reads .value; otherwise it uses the value directly.

Q: Can you nest a ref inside a reactive object?
A: Yes. reactive({ count: ref(0) }) works, and Vue auto-unwraps the nested ref so state.count works without .value. That said, mixing the two adds mental overhead. Picking one system and sticking to it is cleaner.

Q: What is shallowRef vs shallowReactive?
A: Both skip deep reactivity. shallowRef only reacts when .value is reassigned, not when inner properties change. shallowReactive tracks only top-level properties. Both are useful for large objects where deep tracking carries a real cost.

Q: Is there a performance difference for large objects?
A: One reactive proxy is faster than many individual ref wrappers for objects with thousands of properties. The difference is measurable at that scale, but negligible in typical component state.

Examples

Basic: counter and form state

vue
<script setup> import { ref, reactive } from 'vue' // ref for a simple counter const count = ref(0) // reactive for a form (the object is only mutated, never replaced) const form = reactive({ name: '', email: '', preferences: { theme: 'light', notifications: true } }) function resetForm() { form.name = '' form.email = '' form.preferences.theme = 'light' // form = {} would drop the proxy here - don't do it } </script> <template> <button @click="count++">Count: {{ count }}</button> <form @submit.prevent> <input v-model="form.name" placeholder="Name" /> <input v-model="form.email" placeholder="Email" /> <label> <input type="checkbox" v-model="form.preferences.notifications" /> Notifications </label> </form> <button @click="resetForm">Reset</button> </template>

count uses ref because it is a number that needs .value in script. form uses reactive because it is an object that gets mutated in place and never replaced whole.

Edge case: reassignment and reactivity loss

This trips developers moving from Vue 2 or mixing both APIs in the same component.

vue
<script setup> import { ref, reactive } from 'vue' const objRef = ref({ count: 0 }) const objReactive = reactive({ count: 0 }) // ref: reassigning .value creates a fresh proxy, reactivity stays intact objRef.value = { count: 10 } console.log(objRef.value.count) // 10 - template updates correctly // reactive: reassigning the variable drops the proxy entirely // objReactive = { count: 10 } // JS runs, but Vue loses track of it // Safe way to update reactive - mutate properties instead Object.assign(objReactive, { count: 10 }) // OK console.log(objReactive.count) // 10 - template still updates </script>

ref owns the proxy through .value. reactive IS the proxy. Replace the variable and the proxy disappears.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?