Skip to content
TutorialBeginner

ref() or reactive()? How Vue tracks your data

Both ref() and reactive() make data that updates the page. One of them has a trap you will fall into once.

vuejs.school team23 Jan 20256 min readVue 3.5

A plain JavaScript variable can change without the page noticing: let count = 0; count++ changes the number, and nothing tells Vue to update the screen. Vue reacts only to changes it can see, and ref() and reactive() are the two ways to give it data it can see.

The visible difference is .value: a ref needs it in script, a reactive() object does not. The difference that matters more is easy to miss: destructure a number or a string out of a reactive() object (let { visits } = user) and you get a copy Vue no longer tracks, with no error. That trap is one of three reasons the Vue documentation recommends ref() as the default.

How Vue notices a change

Reactivity is a simple promise: when a value changes, everything that depends on it is worked out again. The page re-renders, a computed total is recalculated, a watcher runs. You do not call any of that yourself.

To keep that promise, Vue has to notice two things: when a value is read and when it is written. A plain variable gives it no way to notice either. So Vue wraps your data in objects it can watch, and works in two steps:

  1. Track. While Vue renders a component or runs a computed or a watcher, each property it reads on one of these objects is recorded as a dependency of that work.
  2. Trigger. When you write to that property, Vue tells everything that recorded it to update.

Both ref() and reactive() build those watchable objects.

ref() for one value

ref() takes any value and returns a ref: a small object that holds your value in its value property.

import { ref } from 'vue'

const count = ref(0)
const name = ref('Asha')

count.value++
name.value = 'Asha K.'

The .value is not decoration. A ref is an object with a getter and a setter on value. When you read count.value, the getter records that something depends on count. When you write it, the setter triggers an update. A number on its own cannot do that, which is why the number has to live inside an object.

A ref can hold anything: a string, a boolean, an array, an object, null. When it holds an object, Vue makes that object deeply reactive too, so user.value.name = 'Asha' is tracked just like user.value = newUser.

Leave out .value in the template

In a template, Vue removes the noise. A top-level ref (one declared directly in <script setup>) is unwrapped: Vue reads its .value for you. So you write {{ count }} and @click="count++", not count.value:

Counter.vue
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">Clicked {{ count }} times</button>
</template>

The rule is: .value in script, no .value in the template.

Unwrapping has one limit. It applies only to top-level refs, not to refs nested inside a plain object. If const box = { id: ref(1) }, then {{ box.id + 1 }} renders [object Object]1, because box.id is still the ref object when the expression is evaluated. Keep refs at the top level of your script and this never comes up.

reactive() for an object

reactive() takes an object and returns a reactive proxy of it. A proxy is a wrapper that looks exactly like the original object but intercepts every property read and write. There is no .value; you use the properties directly.

import { reactive } from 'vue'

const user = reactive({ name: 'Asha', visits: 1 })

user.visits++
user.name = 'Asha K.'

That reads nicely, and for a group of related fields, such as a form, it can be the clearer choice. But reactive() only works with object types: plain objects, arrays, and collections such as Map and Set. reactive(0) gives you back the plain 0, because there is no object to wrap, and in development Vue warns value cannot be made reactive: 0.

One detail trips people up: the proxy is a different object from the one you pass in. Say you write const original = { name: 'Asha', visits: 1 } and then const user = reactive(original). user === original is false, and changing original.visits directly does not tell Vue, because only the proxy reports reads and writes. Use what reactive() returned and leave the original alone.

Destructuring reactive() loses reactivity

Destructuring means pulling properties out of an object into separate variables, like const { visits } = user. It is normal JavaScript and you will do it by reflex. Here it is in the <script setup> of Profile.vue, with a function that counts a visit:

Profile.vueBreaks
import { reactive } from 'vue'

const user = reactive({ name: 'Asha', visits: 1 })
let { visits } = user

function recordVisit() {
  visits++
}

visits is now a plain number, a copy of the value at the moment you destructured. Vue cannot see reads or writes of a plain number. recordVisit() adds one to the copy, user.visits stays at 1, and the page does not change. There is no error and no warning, which is what makes this trap expensive.

The same thing happens when you pass user.visits into a function: the function receives a number, not a connection to user.

The fix is toRefs(), which turns each property of a reactive object into a ref that stays connected:

Profile.vueWorks
import { reactive, toRefs } from 'vue'

const user = reactive({ name: 'Asha', visits: 1 })
const { visits } = toRefs(user)

function recordVisit() {
  visits.value++
}

Now visits is a ref pointing at user.visits. Writing visits.value++ updates user, and the page updates with it. Notice the .value is back. That is not a coincidence: the connection to Vue needs an object, and .value is the sign that you have one.

The simpler fix is to not use reactive() for this at all. Two refs, name and visits, cannot be destructured wrongly, because there is nothing to destructure.

Why the docs recommend ref()

The Vue documentation lists three limitations of reactive() and, because of them, recommends ref() as the primary API for declaring reactive state:

  1. It only holds object types. Strings, numbers and booleans need a ref anyway, so a component that uses reactive() ends up mixing both styles.
  2. You cannot replace the whole object. state = reactive({ count: 1 }) points your variable at a new proxy, but anything that was tracking the old proxy keeps tracking it and never hears about the new one. With a ref, state.value = { count: 1 } is fine, because the ref itself is what is tracked.
  3. It is not destructure-friendly, as you just saw.

A ref has none of these problems. The cost is .value in script, and after a week you stop seeing it. Our lessons use ref() for state unless there is a specific reason not to.

shallowRef() for large data

Vue's reactivity is deep. When a ref holds an object, each nested object gets its own proxy the first time it is read, and every property read during a render is tracked.

For a form with ten fields that costs nothing. For a list of twenty thousand rows from an API, the tracking adds work to each render of the list, and you probably never change a single cell in place anyway. The Vue docs' performance guide says the overhead typically becomes noticeable when a single render reads 100,000 or more properties.

shallowRef() tracks only the .value assignment, not the contents. Here it holds rows loaded from an API (/api/rows stands in for your own endpoint):

import { shallowRef } from 'vue'

interface Row {
  id: number
  name: string
}

const rows = shallowRef<Row[]>([])

async function load() {
  const res = await fetch('/api/rows')
  rows.value = await res.json()
}

Replacing rows.value updates the page. Mutating rows.value[0].name does not, by design. Use it when the data is big and you treat it as a block that is swapped out, not edited.

Check it stuck3 questions from the Reactivity basics quiz.
Practice now
Was this helpful?

Keep reading