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.valuein script, auto-unwrapped in templatesreactive= a live proxy of an object; access properties directly, no wrapper at allrefaccepts primitives and objects;reactiveonly works with objects (throws on primitives)- Need to replace the whole value? Only
reflets you do that safely - Not sure which to pick? The Vue team now recommends
refas the default
Quick example
<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, string →
ref. Straightforward and reassignable. - Form object with nested fields →
reactiveif you never replace the whole form;refif you reset it with a fresh object. - API response →
ref. The value might benullbefore the request resolves, then an object or array. - Array of items →
ref. You can replace the whole array orpushinto it. - Unsure? Many teams use
reffor everything and reach forreactiveonly when dot-notation genuinely simplifies the code.
Comparison table
| Feature | ref | reactive |
|---|---|---|
| Input types | Primitive or object | Object / array only |
| Script access | .value required | Direct property access |
| Template access | Auto-unwrapped | Direct property access |
| Full reassignment | Yes (ref.value = newObj) | No (breaks the proxy) |
| Primitives | Yes (ref(42)) | No (throws error) |
| Destructuring | Safe (ref stays reactive) | Loses reactivity; use toRefs |
| TypeScript type | Ref<T> | Original type |
| Reach for it when | Counters, dynamic values, any primitive | Fixed-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:
const count = reactive(0) // Error: reactive() must be called on an objectFix: const count = ref(0).
Forgetting .value in script:
const count = ref(0)
count++ // Wrong: mutates the wrapper reference, not the inner value
// count.value stays 0Fix: count.value++ or ++count.value.
Reassigning a reactive variable:
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:
const state = reactive({ name: 'Alice', age: 25 })
let { name } = state // name is now a plain string, not reactive
name = 'Bob' // template does not updateFix: const { name } = toRefs(state), then name.value = 'Bob'.
Real-world usage
- Pinia stores: often use
reactivefor the root state object (fixed shape, direct mutations) - Nuxt
useFetch: returnsdataas arefbecause the value starts asnulland resolves later - VueUse composables: almost all use
refinternally (useStorage,useLocalStorage, and most others) - VeeValidate / FormKit:
reactivefor form objects with nested field state - Component state: in most codebases I have seen, teams that start mixing both end up standardizing on
refafter 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
<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.
<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 readyA concise answer to help you respond confidently on this topic during an interview.