Most screens show data that is worked out from other data: a list filtered by a search box, a total from a cart, a "select all" checkbox that depends on every row. In Vue the tool for that is computed, a value that Vue derives from your state and keeps up to date.
To use it well, you need to know when it runs again. That tells you when a computed beats a plain function, and why one can keep showing the old total after the user applies a discount. The examples are plain Vue 3.5 with <script setup lang="ts">.
A function in the template runs on every render
Start with a shop page: a search box, a sort choice, and a list. A first version often puts the work in a function and calls it from the template.
<script setup lang="ts">
import { ref } from 'vue'
type Product = { id: number; name: string; price: number }
const products = ref<Product[]>([
{ id: 1, name: 'Notebook', price: 4 },
{ id: 2, name: 'Fountain pen', price: 28 },
{ id: 3, name: 'Desk lamp', price: 35 },
{ id: 4, name: 'Pencil case', price: 9 },
])
const query = ref('')
const sortBy = ref<'name' | 'price'>('name')
const likes = ref(0)
function visibleProducts() {
console.count('visibleProducts ran')
const q = query.value.trim().toLowerCase()
return products.value
.filter(p => p.name.toLowerCase().includes(q))
.sort((a, b) => (sortBy.value === 'name' ? a.name.localeCompare(b.name) : a.price - b.price))
}
</script>
<template>
<input v-model="query" placeholder="Search">
<p>{{ visibleProducts().length }} products</p>
<ul>
<li v-for="p in visibleProducts()" :key="p.id">{{ p.name }}, £{{ p.price }}</li>
</ul>
<button type="button" @click="likes++">Like this shop ({{ likes }})</button>
</template>
It works, and the list is right. Open the console, though. Every render calls visibleProducts() twice, once for the count and once for the list. Click the Like button and the counter goes up by two again, even though the products, the query and the sort did not change. The component re-rendered because likes changed, and a function in the template runs on every render, whatever caused it.
Four products cost nothing. Four thousand, filtered and sorted twice on every render, start to show.
Why the computed runs once per change
In <script setup>, replace the function with a computed:
import { computed, ref } from 'vue'
const visibleProducts = computed(() => {
console.count('visibleProducts ran')
const q = query.value.trim().toLowerCase()
return products.value
.filter(p => p.name.toLowerCase().includes(q))
.sort((a, b) => (sortBy.value === 'name' ? a.name.localeCompare(b.name) : a.price - b.price))
})
In the template, drop the brackets: visibleProducts.length and v-for="p in visibleProducts". A computed is a ref, so in script you read it as visibleProducts.value, and the template unwraps it for you.
Now the console shows one run when the page loads, and one run per keystroke in the search box. The Like button shows none. That is the cache at work. Here is what Vue does with the getter, the function you pass to computed:
- While the getter runs, Vue records every reactive value it reads:
query,sortBy,products, and every product field the filter and the sort touched. Those are its dependencies. - Vue caches the result. Until a dependency changes, every read returns it straight away, so the count and the list share one run.
- When one of the dependencies changes, Vue marks the computed as out of date, and the next read runs the getter again.
Two details follow from that:
- A computed is lazy. If nothing reads it, for example because the only template code that uses it sits inside a
v-ifthat is false, the getter does not run at all. - Since Vue 3.4, a computed tells the things that read it to update only when its new value differs from the old one. A computed returning
truethat recalculates totrueagain triggers nothing.
The .sort() here is safe because .filter() already returned a new array. Sorting products.value directly would reorder your source data from inside a getter, which is the next mistake.
Keep the getter pure
The Vue guide puts it plainly: a computed getter should only compute and return a value. No changing other state, no requests, no touching the DOM. The tempting version is a computed that loads data:
<script setup lang="ts">
import { computed } from 'vue'
type User = { id: number; name: string }
const props = defineProps<{ userId: number }>()
const user = computed(async () => {
const res = await fetch(`/api/users/${props.userId}`)
return (await res.json()) as User
})
</script>
<template>
<p>{{ user.name }}</p>
</template>
An async function always returns a Promise, so user holds a Promise, not a user. The paragraph stays empty, and vue-tsc reports Property 'name' does not exist on type 'Promise<User>'.
Loading data is a side effect: it happens in response to a change. That is the job of a watcher.
<script setup lang="ts">
import { ref, watch } from 'vue'
type User = { id: number; name: string }
const props = defineProps<{ userId: number }>()
const user = ref<User | null>(null)
watch(
() => props.userId,
async (id) => {
const res = await fetch(`/api/users/${id}`)
user.value = (await res.json()) as User
},
{ immediate: true },
)
</script>
<template>
<p v-if="user">{{ user.name }}</p>
<p v-else>Loading…</p>
</template>
The watcher runs once straight away because of immediate: true, then again whenever userId changes, and writes the result into a ref. The same rule covers smaller side effects: a getter that saves to localStorage, logs analytics or sets another ref belongs in a watcher too.
A computed you can set
A computed is read-only by default. Assigning to one prints [Vue warn] Write operation failed: computed value is readonly. Sometimes a derived value should be editable, and a "mark all as done" checkbox is the classic case. Pass an object with get and set:
<script setup lang="ts">
import { computed, ref } from 'vue'
type Todo = { id: number; text: string; done: boolean }
const todos = ref<Todo[]>([
{ id: 1, text: 'Read the lesson', done: true },
{ id: 2, text: 'Run the example', done: false },
{ id: 3, text: 'Take the quiz', done: false },
])
const allDone = computed({
get: () => todos.value.length > 0 && todos.value.every(t => t.done),
set: (value: boolean) => {
for (const t of todos.value) t.done = value
},
})
</script>
<template>
<label><input v-model="allDone" type="checkbox"> Mark all as done</label>
<ul>
<li v-for="t in todos" :key="t.id">
<label><input v-model="t.done" type="checkbox"> {{ t.text }}</label>
</li>
</ul>
</template>
Tick every row and the top box ticks itself, because the getter re-runs. Tick the top box and the setter writes to every row. Notice that the setter does not store anything of its own. It changes the source state, and the getter derives the new value from that.
Chain computeds so each step caches separately
A computed can read another computed, and each keeps its own cache. In the shop list, split visibleProducts into a filter step and a sort step, and add a total. The template now reads sorted and total:
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
return products.value.filter(p => p.name.toLowerCase().includes(q))
})
const sorted = computed(() =>
[...filtered.value].sort((a, b) =>
sortBy.value === 'name' ? a.name.localeCompare(b.name) : a.price - b.price,
),
)
const total = computed(() => sorted.value.reduce((sum, p) => sum + p.price, 0))
Changing the sort now re-runs sorted and total, but not filtered, because the query and the products did not change. Typing in the search box runs all three. Note the [...filtered.value] copy: .sort() reorders the array it is called on, and that array belongs to filtered's cache.
A computed goes stale when an input is not reactive
A computed only tracks reactive values: refs, reactive() objects, props, other computeds. It still reads anything else when the getter runs, but a change to it does not mark the computed as out of date.
<script setup lang="ts">
import { computed, ref } from 'vue'
const subtotal = ref(40)
let discount = 0
const total = computed(() => subtotal.value - discount)
function applyCode() {
discount = 5
}
</script>
<template>
<p>Total: £{{ total }}</p>
<button type="button" @click="applyCode">Apply code</button>
</template>
Click the button and the total stays at £40. discount is a plain variable, so Vue never saw it as a dependency and nothing marks the computed as out of date. The confusing part comes later: change subtotal and the total suddenly includes the discount, because the getter finally ran again. Make every input reactive:
const subtotal = ref(40)
const discount = ref(0)
const total = computed(() => subtotal.value - discount.value)
function applyCode() {
discount.value = 5
}
The same goes for Date.now(), localStorage.getItem() and window.innerWidth. A computed that reads them keeps the answer from its last run until one of its reactive inputs changes. Put the value in a ref and update the ref when it changes, for example from a timer or an event listener.
Debugging with onTrack and onTrigger
When a computed updates too often or not at all, ask Vue what it tracked. computed takes a second argument with two callbacks. Here they are on filtered:
const filtered = computed(
() => {
const q = query.value.trim().toLowerCase()
return products.value.filter(p => p.name.toLowerCase().includes(q))
},
{
onTrack(e) {
console.log('tracked', e.target, e.key)
},
onTrigger(e) {
console.log('out of date because', e.key, 'became', e.newValue)
},
},
)
onTrack fires once for every reactive read during the getter: the query ref, the products ref, the array, and each product's name. For a ref, e.key is 'value' and e.target is the ref itself. onTrigger fires when one of those changes and marks the computed out of date.
Both callbacks only run in development builds; Vue ignores them in production. To pause instead of logging, put a debugger statement in either callback.
If a value you expected is missing from the onTrack list, the getter either did not read it on that run or read something that is not reactive. The second is the stale computed from the previous section, and the fix is to put the value in a ref.