Choosing Between Them
ref is the default and reactive is the exception: how to choose per piece of state, with both forms written out.
ref(anyValue) for one value or a replaceable object; reactive(object) for grouped state you only mutate in placeBoth ref and reactive store state Vue can track. The choice is made once per piece of state, when you declare it.
The rule
ref is the default. It takes any value, including a number, a string or null. It can be replaced outright. It survives being passed around, because what moves is the box, not the value. The cost is .value everywhere in script.
reactive reads better when several fields always travel together and you want state.query with no .value at all. The cost is the two failure modes: destructuring it and reassigning it each lose reactivity, silently.
Worked
A count, a loading flag, a selected id: ref. A draft record you clear and refill: ref holding the object, so clearing is one assignment. A filter object whose shape never changes and which you only mutate in place: reactive is fine, and the code is shorter.
What trips people up
Mixing them. A ref placed in a reactive object is unwrapped, so state.count is the number and not the ref. That unwrapping does not happen inside arrays, Map or Set, where you still read .value at runtime. Vue's own recommendation is to use ref unless you have a reason not to, so a codebase stays predictable.
Example
<!-- src/components/DraftEditor.vue -->
<script setup lang="ts">
import { ref } from 'vue'
interface Draft {
title: string
body: string
}
const saving = ref(false)
const draft = ref<Draft>({ title: '', body: '' })
function onTitle(event: Event): void {
draft.value.title = (event.target as HTMLInputElement).value
}
function clear(): void {
// One assignment resets every field: a ref can be replaced.
draft.value = { title: '', body: '' }
}
</script>
<template>
<!-- draft is top level, so the template reads draft.title, not draft.value.title -->
<input type="text" :value="draft.title" @input="onTitle" />
<p>{{ draft.title.length }} characters</p>
<button type="button" :disabled="saving" @click="clear">clear</button>
</template>
<style scoped>
input {
font: inherit;
}
</style>When to use it
- Decide how to declare a new piece of component state without re-litigating it later.
- Convert a reactive object that someone keeps destructuring into refs.
- Explain in review why a reassigned reactive object stopped updating the page.
More examples
The same state written with reactive
Identical behaviour, so you can see what the choice actually costs. No .value in script, but clear() grows with every field added, and adding a fourth field means remembering to reset it here too. With two or three stable fields this version is pleasant; with a record you replace wholesale it is a liability.
<!-- src/components/DraftEditorReactive.vue -->
<script setup lang="ts">
import { reactive } from 'vue'
const draft = reactive({ title: '', body: '' })
function onTitle(event: Event): void {
draft.title = (event.target as HTMLInputElement).value
}
function clear(): void {
// draft cannot be reassigned, so each field is reset by hand.
draft.title = ''
draft.body = ''
}
</script>
<template>
<input type="text" :value="draft.title" @input="onTitle" />
<p>{{ draft.title.length }} characters</p>
<button type="button" @click="clear">clear</button>
</template>reactive plus toRefs when you want short names
The middle road: group the state, then convert it so destructuring is safe. toRefs only creates refs for properties that exist when it is called, so a property added later is not covered; use toRef(state, 'key') for that one. Reach for this when a state object has to be taken apart.
// src/lib/pagination.ts
import { reactive, toRefs } from 'vue'
const state = reactive({ page: 1, size: 20 })
const { page, size } = toRefs(state)
page.value += 1
console.log(state.page, size.value)
// Output: 2 20A ref inside a reactive object is unwrapped
Explains code that looks wrong at first sight: a ref nested in a reactive object loses its .value and stays linked to the original ref. At runtime that unwrapping does not apply inside arrays, Map or Set, which is a good reason not to mix the two styles in one object.
// src/lib/mixed.ts
import { reactive, ref } from 'vue'
const count = ref(0)
const state = reactive({ count })
// state.count is the number, not the ref.
console.log(state.count)
// Output: 0
// Writing either one updates both: they are the same cell.
state.count = 3
console.log(count.value)
// Output: 3
Discussion