04 · Reactivity: ref, reactive, computed¶
Every interactive Vue component is built on three functions: ref() for reactive values,
reactive() for reactive objects, and computed() for values derived from them. This
lesson explains each one, when to reach for which, and the rules that make them behave
predictably. All console output shown was captured from Vue 3.5.43 in a Vitest run.
ref()¶
ref() wraps any value — number, string, object, array, null — in an object with a
single reactive property, .value:
import { ref } from 'vue'
const count = ref(0) // Ref<number>
count.value++ // write
console.log(count.value) // read: 1
const user = ref<{ name: string } | null>(null)
user.value = { name: 'Ada' }
In the template, top-level refs are unwrapped automatically, so you write {{ count }}
and @click="count++", not .value.
If you pass an object to ref(), its .value is made deeply reactive:
const cart = ref({ items: [1, 2] })
cart.value.items.push(3) // tracked — components reading cart.value.items update
We verified that isReactive(cart.value) is true: ref hands objects to reactive()
internally.
reactive()¶
reactive() takes an object and returns a reactive proxy of it. No .value needed:
import { reactive } from 'vue'
const form = reactive({
email: '',
password: '',
remember: false,
})
form.email = 'ada@example.com' // tracked
Nested objects are reactive too — isReactive(state.nested) came back true in our run.
The proxy is not the original object:
That line came from:
const raw = { n: 1 }
const r = reactive(raw)
console.log('proxy===raw', r === raw,
'same proxy twice', reactive(raw) === r,
'toRaw', toRaw(r) === raw)
Only changes made through the proxy are tracked. If some other code keeps a reference
to raw and mutates it, Vue never finds out.
The limits of reactive()¶
- It only works on objects (including arrays,
Map,Set).reactive(0)gives a warning and a plain number. - You can't replace the whole thing.
let state = reactive({...}); state = reactive({...})disconnects everything that was reading the first proxy. - Destructuring breaks the connection. This is the big one.
const state = reactive({ count: 0 })
let { count } = state
count++
console.log(state.count) // 0 — 'count' was a plain number copy
Our run printed destructured count++ -> state.count 0. When you destructure, you read
the current value out of the proxy into a local variable. To destructure and keep
reactivity, convert to refs first:
import { toRefs } from 'vue'
const { count } = toRefs(state) // count is a Ref linked to state.count
count.value++
console.log(state.count) // 1
Which one should I use?¶
The official recommendation — and this course's convention — is ref() as the default.
Reasons:
- it works for every kind of value, including primitives and
null; - you can replace the whole value (
items.value = await fetchItems()); - the
.valuemakes reactive reads visible when you scan the code; - refs survive being passed into functions and returned from composables (Level 2).
reactive() is pleasant for a cohesive group of fields that you always use together
through the object, such as form state. Just never destructure it.
Ref unwrapping rules¶
Refs are unwrapped in a few places and not others. From our run:
const inner = ref(5)
const box = reactive({ inner })
box.inner // 5 — unwrapped when accessed as a property of a reactive object
const arr = reactive([ref(1)])
isRef(arr[0]) // true — NOT unwrapped inside reactive arrays (or Map/Set)
In templates, unwrapping applies only to top-level bindings. If you have
const obj = { id: ref(1) } (a plain object holding a ref), then {{ obj.id + 1 }} gives
[object Object]1. Destructure id to the top level or use obj.id.value.
computed()¶
computed() derives a value from other reactive state:
import { ref, computed } from 'vue'
const items = ref([
{ name: 'Coffee', price: 4, qty: 2 },
{ name: 'Bagel', price: 3, qty: 1 },
])
const total = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.qty, 0),
)
total.value // 11
You never list dependencies — whatever reactive values the getter reads while running become its dependencies. Computed values are lazy and cached. We counted getter runs:
- Creating a computed runs nothing.
- Two reads ran the getter once — the second read used the cache.
- Setting
adid not run the getter; it only marked the cache as stale. - The next read recomputed.
- Setting
ato the value it already had (3→3) triggered nothing at all.
That makes computed the right tool for filtering, sorting and totals. A method called
from the template, by contrast, runs on every render of the component.
Writable computed¶
Occasionally you want a derived value that can also be assigned. Provide get and set:
const first = ref('Ada')
const last = ref('Lovelace')
const fullName = computed({
get: () => `${first.value} ${last.value}`,
set: (value: string) => {
const [f = '', l = ''] = value.split(' ')
first.value = f
last.value = l
},
})
fullName.value = 'Grace Hopper' // first = 'Grace', last = 'Hopper'
Our run confirmed first.value, last.value became Grace Hopper. Use this sparingly —
most of the time, a computed should be read-only.
Worked example: a filtered, sorted list¶
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Task { id: number; title: string; done: boolean; priority: 1 | 2 | 3 }
const tasks = ref<Task[]>([
{ id: 1, title: 'Write tests', done: false, priority: 2 },
{ id: 2, title: 'Fix login bug', done: false, priority: 1 },
{ id: 3, title: 'Update README', done: true, priority: 3 },
])
const hideDone = ref(false)
const query = ref('')
const visible = computed(() =>
tasks.value
.filter((t) => !hideDone.value || !t.done)
.filter((t) => t.title.toLowerCase().includes(query.value.toLowerCase()))
.sort((a, b) => a.priority - b.priority), // safe: filter() already made a new array
)
const remaining = computed(() => tasks.value.filter((t) => !t.done).length)
let nextId = 4
function add(title: string) {
tasks.value.push({ id: nextId++, title, done: false, priority: 2 })
}
</script>
<template>
<input v-model="query" placeholder="Filter tasks" />
<label><input type="checkbox" v-model="hideDone" /> Hide done</label>
<p>{{ remaining }} remaining</p>
<ul>
<li v-for="t in visible" :key="t.id">
<label><input type="checkbox" v-model="t.done" /> {{ t.title }}</label>
</li>
</ul>
<button type="button" @click="add('New task')">Add</button>
</template>
Checking a box writes t.done on an object inside tasks.value. That object is reactive
(it came through ref), so both visible and remaining are invalidated and the list
updates. Note that .sort() is only safe here because filter() has already produced a
new array. Never mutate reactive state inside a computed getter — calling
tasks.value.sort() directly would reorder the source array itself, triggering every
effect that depends on it (including the computed that is currently running). The
non-mutating toSorted() also works at runtime in current browsers, but the generated
tsconfig doesn't include the ES2023 library types, so vue-tsc rejects it unless you
raise lib; we hit exactly that error when type-checking this example.
How It Actually Works¶
reactive() returns a JavaScript Proxy. Its get trap calls track(target, key),
which records "the currently running effect depends on target.key". Its set trap
calls trigger(target, key), which finds every effect that depends on that key and
schedules it. A ref is a small object whose value getter and setter do the same
tracking and triggering on a single property — which is why refs can hold primitives
(you can't proxy a number) and why you need .value in script (the getter is the
interception point).
"The currently running effect" is the key idea. When a component renders, Vue runs its
render function inside a reactive effect; every track call during that run adds the
component to a dependency list. A computed is also an effect, but a lazy one: when a
dependency triggers, it just flips a dirty flag and notifies its own subscribers.
Recomputation happens on the next .value read. In Vue 3.5 each computed also remembers
a version number for every dependency, so it can tell cheaply whether anything it read
really changed — which is why setting a to the same value caused zero reruns above
(and ref writes compare with Object.is first, so that write didn't even trigger).
Destructuring breaks things because a plain variable has no getter. let { count } = state
executes one get on the proxy and copies the number out; subsequent reads of count
never hit the proxy, so nothing is tracked. toRefs creates refs whose getters read
state.count on every access, restoring the interception.
Common mistakes¶
- Destructuring
reactive()or props-like objects and expecting updates. UsetoRefs, or just userefeverywhere. - Reassigning a
reactivevariable. Mutate its properties or useObject.assign, or switch torefand assign.value. - Side effects in
computed— fetching data, mutating state, writing to storage. Computed getters must be pure; side effects belong in watchers (Lesson 05). - Mutating the result of a computed.
visible.value.push(x)modifies a derived array that will be thrown away on the next recompute. Change the source instead. - Comparing proxies to raw objects with
===. Afterlist.push(item)on a reactive array,list[list.length - 1] === itemisfalse— the array hands back a proxy ofitem. (Vue does patchincludes,indexOfandlastIndexOfto also match the raw object; we checked, andlist.includes(item)returnedtrue.) Comparing by id avoids the question entirely.
Exercise¶
Build TaskList.vue above, then extend it:
- Add a
computedcalledprogressthat returns the percentage of tasks done, rounded to a whole number, and show it in a<progress>element. - Add a "Sort by"
<select>(priority / title) stored in aref, and makevisiblerespect it. - Add a
console.log('recomputing')insidevisible's getter. Type in the filter box and toggle "Hide done" — then click a button that changes something unrelated (add aconst clicks = ref(0)and a button that increments it and displays it). Confirm the log does not fire for the unrelated change. Remove the log when done. - Refactor
tasksfromref<Task[]>toreactive<Task[]>and see which lines must change. Which version do you prefer, and why?