Reactivity — choosing between ref and reactive
Write count.value += 1 and the number on screen changes. All you did was overwrite a value; nowhere did you write an instruction to render. Vue is watching reads and writes of the value, remembering where it was read, and redrawing those places when it is overwritten.
That watching has two entry points: ref() and reactive(). Which one you pick changes how you write, and picking wrong fails not by "raising an error and stopping" but by "quietly breaking tracking so the screen never updates". Because there is no error, the cause is hard to find, and this is the first place people stumble when they start writing Vue.
What you'll learn in this chapter
- Decide whether to use
reforreactivebased on the shape of the value - Explain, from first principles, where
.valueis needed and where it is not - Explain the mechanism by which reassignment and destructuring break tracking
- Understand what
toRefspreserves, and use it where it is needed
ref — wrapping a value to catch reads and writes
ref() wraps whatever you pass it in an object with a value property.
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const increment = () => {
count.value += 1
}
</script>
<template>
<p>Count: {{ count }}</p>
<button @click="increment">+1</button>
</template>
Why does it need wrapping? JavaScript offers no way to detect an assignment to a variable from the outside. If you write let count = 0 and then count = 1, there is no way for a third party to know that assignment happened.
Object properties, on the other hand, let you intercept reads and writes by defining accessors. So Vue moves the value inside an object and brings reads and writes of value under its own control. On read it records "the render currently running used this value"; on write it notifies the recorded work to run again.
The internal implementation is a class with accessors. The value getter records the dependency and the setter notifies of the change. A ref itself is not a Proxy. When you pass an object, though, the contents that go into .value are put through reactive(), so that part is a Proxy. This two-layer structure matters later when we get to toRefs.
Where .value is needed and where it is not
You need .value inside the script, but not in the template.
<script setup lang="ts">
import { ref } from 'vue'
const message = ref('hello')
// .value is required inside the script
console.log(message.value)
</script>
<template>
<!-- unwrapped automatically in the template -->
<p>{{ message }}</p>
</template>
The asymmetry looks unhelpful, but there is a reason. Values the template refers to are read through a proxy Vue set up. If what that proxy reads is a ref, it returns the contents, so you get the value without writing .value. The script just runs as ordinary JavaScript, so the same intervention is not possible. The split is that only the side where Vue can intercept the read gets to omit it.
What gets unwrapped automatically is a ref bound at the top level of script setup. A ref placed in a property of reactive() is also unwrapped at the moment you read the property.
A ref put inside a plain object and passed along is handled inconsistently. Displaying the value directly, as in {{ box.count }}, unwraps it, but using it inside an expression such as {{ box.count + 1 }} or passing it to an attribute leaves it as the ref object. The safe rule is do not wrap values you pass to the template in a plain object.
When to write a type argument
If the type inferred from the initial value is exactly what you want, there is no need to write a type argument.
import { ref } from 'vue'
// inferred as Ref<number>; no type argument needed
const count = ref(0)
// inference would give Ref<null>, so widen it with a type argument
const selectedId = ref<number | null>(null)
// inference would widen to Ref<string>, so narrow it with a type argument
const theme = ref<'light' | 'dark'>('light')
Writing ref(0) as ref<number>(0) changes nothing. A type argument earns its place when you want to change the result of inference, and there are two directions. A variable that starts as null and receives a value later needs the type widened; a variable that should only hold a fixed set of options needs it narrowed. If it is neither, the type argument just restates what inference already gave you. This guide defaults to leaving it to inference, but that is this guide's policy rather than a rule laid down by the official style guide.
reactive — wrapping the whole object in a Proxy
reactive() wraps an object in a Proxy. Property access goes through the Proxy, so .value is not needed.
<script setup lang="ts">
import { reactive } from 'vue'
const user = reactive({
name: 'Tanaka',
age: 30,
})
const celebrateBirthday = () => {
// no .value needed; nested properties are tracked too
user.age += 1
}
</script>
<template>
<p>{{ user.name }} ({{ user.age }})</p>
<button @click="celebrateBirthday">Birthday</button>
</template>
It reads naturally, but the official documentation lists three limitations1.
| Limitation | Details |
|---|---|
| Limited to certain types | Objects, arrays, Map, and Set only. It cannot be used on numbers or strings |
| The whole thing cannot be replaced | Assigning a different object to the variable breaks tracking |
| Extracting breaks it | Destructuring a property or passing it to a function means it is no longer tracked from there on |
The way the official documentation recommends declaring state in the Composition API is ref()2. Because of the limitations above, reactive is not something you "must not use" but something you "choose knowing its limits".
Two patterns that break tracking
The second and third limitations show up in forms that are easy to walk into in real code.
Reassigning the variable
import { reactive } from 'vue'
let state = reactive({ count: 0 })
// assignment to a plain variable is not tracked, so no re-render happens here
state = reactive({ count: 10 })
The reason is not "the template keeps holding the old Proxy". The template reads the variable each time and sees the value as of that moment. Nothing is reflected because assignment to a plain variable is not something Vue tracks, so no re-render occurs.
That difference is visible in the behaviour. Change some other reactive value in the same component to trigger a re-render, and only then does 10 appear. It presents not just as "I changed it and nothing happened" but as "I did something unrelated and it suddenly appeared", which makes it a hard class of bug to pin down.
If you want to swap the whole thing out, use ref. The container stays fixed and only the contents are replaced.
import { ref } from 'vue'
const state = ref({ count: 0 })
// the container is the same and only the contents change, so this is reflected
state.value = { count: 10 }
Destructuring
import { reactive } from 'vue'
const state = reactive({ count: 0 })
// count receives a copy of the number
let { count } = state
// state.count is still 0
count += 1
Destructuring reads a property from the right-hand side and puts that value into a new variable. Only the number as of the read is copied, so no connection remains between the variable and the original property. Passing state.count to a function is the same: what travels is a number.
This is not a limitation of Vue but the mechanics of how JavaScript passes values. A Proxy can intercept reads and writes, but it cannot chase a number that has been read and taken out.
toRefs — keeping the connection through destructuring
When you want to destructure, put it through toRefs(). It creates a ref per property and keeps it synchronised with the original property in both directions.
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, label: 'total' })
const { count, label } = toRefs(state)
// a change on the ref side reaches the original
count.value += 1
console.log(state.count) // 1
// a change to the original reaches the ref side
state.count += 1
console.log(count.value) // 2
What gets created is not a copy but a ref that points at that property. That is why it travels both ways.
toRefs is a function meant for reactive objects. Passing a ref directly produces a development-build warning saying it expected a reactive object but received a plain one. A ref only ever has the single value property anyway, so the situation of wanting to destructure it out into several variables does not arise.
Where it earns its place is in the return value of a composable. You write the internals with reactive and convert when you want the caller to be able to destructure.
import { reactive, toRefs } from 'vue'
export const useCounter = (initial = 0) => {
const state = reactive({ count: initial, step: 1 })
const increment = () => {
state.count += state.step
}
// return it in a form that survives destructuring
return { ...toRefs(state), increment }
}
Only properties that can be enumerated at the moment of the call are converted. Properties added later get no ref, and for those you use toRef().
Which to use
The decision comes down to the shape of the value and whether you swap it out.
| Situation | What to pick | Why |
|---|---|---|
| Numbers, strings, booleans | ref | reactive cannot handle primitives |
Starts as null and is filled in later | ref (with an explicit type argument) | The container is fixed and the contents can be replaced |
| Replacing an array or object wholesale | ref | Reassignment does not break tracking |
| Handling related values as one unit | Either | reactive lets you write property names, which reads well |
| Returning from a composable | ref, or reactive put through toRefs | Survives destructuring by the caller |
When in doubt, leaning towards ref is the safe choice. It costs you the effort of writing .value, but that is lighter than the burden of remembering the conditions under which tracking breaks.
Summary
refwraps a value in avalueproperty and detects changes by intercepting reads and writes of it. A ref itself is not a Proxy but an object with accessors.valueis only needed in the script. The template reads through Vue's proxy, so it can be omitted. Refs placed in properties ofreactiveare unwrapped too- Write a type argument when you want to widen or narrow the result of inference. If the type from the initial value is right, it is unnecessary
reactivewraps an object in a Proxy. It cannot be used on primitives, and reassigning the variable or destructuring breaks tracking- Reassignment goes unreflected not because the reference is stale but because assignment to a plain variable is not tracked, so no re-render happens
- Tracking breaks because JavaScript copies values when passing them. A Proxy cannot follow a value once it has been taken out
toRefsconverts the properties of a reactive object into refs and preserves the connection in both directions. It is not for use on a ref
- Vue — Reactivity Fundamentals — the official explanation of
refandreactiveand the list of limitations - Vue — Reactivity API: Utilities — the specification of
toRefsandtoRef - React guide — State (useState) — comparing how state is held brings out the difference from a design that embeds tracking in the value
What to read next
- Template syntax and directives — how to project a tracked value onto the screen
- Computed properties and watchers — deriving one value from another, and running work in response to a change
- Composables — revisits the situations where
toRefsis needed