There's a question every Vue developer asks within the first month of using the Composition API: "Should I use ref or reactive?" And the answer they get is some version of "it depends" which doesn't help anyone.
Both wrap data in reactivity. Both update the template when the value changes. Both have escape hatches and gotchas. The difference is in how they behave when you start moving the value around — destructuring it, passing it to a composable, replacing it wholesale.
This article is the practical decision guide. Not an opinion piece — a list of scenarios, what each one does in each shape, and which choice ages well.
What Each One Actually Is
ref(value) returns a reactive wrapper object: { value: T }. You read and write through .value. In templates, Vue's compiler unwraps it automatically (you write {{ count }}, not {{ count.value }}). In script code, you always need .value.
reactive(obj) returns a Proxy over obj. You read and write properties directly. No .value. Only works on objects, arrays, Map, Set — not on primitives.
import { ref, reactive } from 'vue'
const count = ref(0)
count.value++ // .value required outside templates
const state = reactive({ count: 0 })
state.count++ // direct property access
That's the syntactic difference. The behavioural differences come out the moment you try to do anything with the value other than read and write it in the same scope.
The Five Scenarios That Decide The Choice
1. A Single Primitive — Use ref
const query = ref('')
const isOpen = ref(false)
const userId = ref<number | null>(null)
reactive doesn't even work here — it requires an object. Some people wrap primitives in objects (reactive({ value: '' })) to avoid .value, but you've reinvented ref and made the code harder to read.
2. A Small, Internal Object — Use reactive
const form = reactive({
name: '',
email: '',
acceptTerms: false,
})
form.name = 'Ann' // no .value, mutations are direct
reactive reads more naturally for objects. ref({ name: '' }).value.name = 'Ann' works but the .value. is friction. If the object stays inside one component or one composable, reactive is the cleaner choice.
3. A Value Returned From A Composable — Use ref
This is the scenario where the choice has consequences:
// composable
export function useCounter() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
// component
const { count, increment } = useCounter()
console.log(count.value) // ✅ still reactive after destructuring
ref wraps the value in an object, so when you destructure the return, you're copying the object reference — not the underlying value. The reference still points at the same RefImpl, reactivity stays alive.
reactive doesn't survive destructuring:
export function useCounter() {
const state = reactive({ count: 0 })
return state // returning the proxy
}
const { count } = useCounter()
console.log(count) // 0 — but it's a plain number now, not reactive
The destructuring at the call site reads the count property once and binds it to a local variable. Future mutations to state.count don't update count.
The fix is toRefs:
import { reactive, toRefs } from 'vue'
export function useCounter() {
const state = reactive({ count: 0 })
return toRefs(state) // each property becomes a ref bound to the proxy
}
const { count } = useCounter()
console.log(count.value) // ✅ reactive again
toRefs walks the object and replaces each property with a ref that reads/writes through the original Proxy. Destructure-safe.
The official Vue team recommendation tilts toward ref for composable returns specifically because it sidesteps this trap — and the destructuring case is constant in real code.
4. A Value You'll Replace Wholesale — Use ref
const user = ref<User | null>(null)
async function loadUser(id: number) {
user.value = await api.getUser(id) // single assignment
}
ref lets you replace the entire value with one line. With reactive, reassigning the local variable doesn't work (you're just rebinding the local), and you have to Object.assign(state, newState) to mutate properties on the original Proxy:
const state = reactive<User>({ id: 0, name: '' })
state = await api.getUser(id) // ❌ doesn't update reactivity
Object.assign(state, await api.getUser(id)) // ✅ but verbose
Anything you'd genuinely "swap out" — current user, current page data, current document — fits better with ref.
5. A Large, Performance-Sensitive Object — Use shallowRef Or markRaw
For data you display but rarely mutate (large tables, pre-fetched JSON blobs, third-party class instances), the deep-Proxy traversal of reactive costs memory and time. Two escape hatches:
import { shallowRef, markRaw } from 'vue'
// shallowRef: only the .value swap is reactive; nested mutations aren't tracked
const tableData = shallowRef(rows)
tableData.value = newRows // triggers update
tableData.value[0].name = 'x' // does NOT trigger update
// markRaw: permanently opt out — useful for class instances from libs
const player = markRaw(new VideoPlayer()) // Vue won't try to wrap it
The discipline that pairs with shallowRef is immutable updates — replace the whole reference, never mutate inside. Slightly more work at the call site, dramatically cheaper for big data.
The Decision Tree, In One Place
When you're picking between them:
- Is the value a primitive? →
ref. (reactivedoesn't work.) - Will it cross a composable boundary or be destructured? →
ref(orreactive+toRefs). - Will you replace the whole value at once? →
ref. - Is it a stable, internal object that stays in one place? →
reactivereads nicer. - Is it a huge data structure or third-party class? →
shallowReformarkRawafter measuring.
For new code, I default to ref. The .value cost is small; the destructuring trap is large. Once a codebase has dozens of composables and hundreds of components, "one consistent wrapper everywhere" is worth more than "the slightly nicer syntax for objects".
The Vue Team's Own Take
The official Vue docs recommend ref as the default — primarily to keep code consistent and to sidestep the destructuring trap, especially in composables. That's not a hard rule. There are real cases where reactive is fine (form objects that stay inside one component, internal store state). But "default to ref" is the path of least surprise.
A Few Subtle Bugs Worth Knowing
watch(state.count, ...)doesn't work becausestate.countis a plain number at the time of the call, not a reactive source. Use() => state.countto make it a getter, orwatch(toRef(state, 'count'), ...).- Templates auto-unwrap top-level refs but don't unwrap refs inside arrays or nested objects.
arr.value[0]displays correctly;arr[0].valueis what you'd write in script. refof an object does deep reactivity by default —ref({ items: [] }).value.items.push(...)triggers updates. (shallowRefopts out.)reactive(null)warns in dev (value cannot be made reactive) and returns the value unchanged — same behaviour asreactive(primitive).isRef,isReactive,isProxyexist for runtime checks when you genuinely need them. You usually don't.
The One-Sentence Mental Model
ref is a reactive box that survives moving around. reactive is a reactive view of an object that's pinned in place. Move the value across boundaries — ref. Keep it local and treat it like state — reactive is fine, but the consistency wins of using ref everywhere usually outweigh the prettier syntax. Pick once, use it everywhere, and the question stops coming up.




