Skip to main content

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 ref or reactive based on the shape of the value
  • Explain, from first principles, where .value is needed and where it is not
  • Explain the mechanism by which reassignment and destructuring break tracking
  • Understand what toRefs preserves, 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.

components/Counter.vue
<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.

components/Counter.vue (no .value 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.

components/UserCard.vue
<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.

LimitationDetails
Limited to certain typesObjects, arrays, Map, and Set only. It cannot be used on numbers or strings
The whole thing cannot be replacedAssigning a different object to the variable breaks tracking
Extracting breaks itDestructuring 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.

composables/useCounter.ts
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.

SituationWhat to pickWhy
Numbers, strings, booleansrefreactive cannot handle primitives
Starts as null and is filled in laterref (with an explicit type argument)The container is fixed and the contents can be replaced
Replacing an array or object wholesalerefReassignment does not break tracking
Handling related values as one unitEitherreactive lets you write property names, which reads well
Returning from a composableref, or reactive put through toRefsSurvives 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​

  • ref wraps a value in a value property and detects changes by intercepting reads and writes of it. A ref itself is not a Proxy but an object with accessors
  • .value is only needed in the script. The template reads through Vue's proxy, so it can be omitted. Refs placed in properties of reactive are 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
  • reactive wraps 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
  • toRefs converts the properties of a reactive object into refs and preserves the connection in both directions. It is not for use on a ref
Related references

Footnotes​

  1. Source: Reactivity Fundamentals — Limitations of reactive(). It lists three points: object types only, the object cannot be replaced wholesale, and destructuring breaks it. ↩