Computed properties and watchers — separating derivation from side effects
Wanting another value out of your state splits into two cases: "I want a value that can be calculated from this one", and "I want something to happen when this value changes". The former is computed, the latter is watch.
The way to tell them apart is "does it return a result, or cause a side effect?" Producing a total is the former; hitting an API when a value changes is the latter. Mixing them up produces calculations where caching does not apply, and side effects that run an unintended number of times.
What you'll learn in this chapter
- Explain what the caching in
computedis keyed on - Use a writable
computedwhere the situation calls for it - Choose the shape of what
watchobserves, and the behaviour its options give you - Use
watchEffectandwatchfor what each is good at - Register cleanup for asynchronous work correctly
computed — deriving a value from a value
<script setup lang="ts">
import { computed, ref } from 'vue'
type Item = { name: string; price: number; count: number }
const items = ref<Item[]>([
{ name: 'Pen', price: 120, count: 3 },
{ name: 'Notebook', price: 480, count: 1 },
])
// recalculated only when items changes
const total = computed(() => items.value.reduce((sum, item) => sum + item.price * item.count, 0))
</script>
<template>
<p>Total: {{ total }} yen</p>
<p>Total (again): {{ total }} yen</p>
</template>
This template reads total twice, but the calculation runs only once. computed remembers the values it depends on and returns the previous result until one of them changes.
How it differs from a method
The same calculation can be written as a method. The difference is caching.
<template>
<!-- computed: not recalculated while the dependencies are unchanged -->
<p>{{ total }}</p>
<p>{{ total }}</p>
<!-- method: runs on every read -->
<p>{{ calcTotal() }}</p>
<p>{{ calcTotal() }}</p>
</template>
A method runs on every re-render. If the calculation is cheap the difference does not show, but for work like walking an array, the number of runs is the cost.
Caching is keyed on reactive dependencies. Reading a non-reactive value such as Date.now() or Math.random() inside a computed does not record it as a dependency. That is why a computed returning the current time never updates.
Writable computed
A computed is read-only by default, but passing get and set makes it writable.
<script setup lang="ts">
import { computed, ref } from 'vue'
const firstName = ref('Taro')
const lastName = ref('Tanaka')
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value: string) => {
const [first = '', last = ''] = value.split(' ')
firstName.value = first
lastName.value = last
},
})
</script>
<template>
<!-- v-model works against a writable computed too -->
<input v-model="fullName" />
<p>First: {{ firstName }} / Last: {{ lastName }}</p>
</template>
v-model demands both reading and writing, so it pairs with a writable computed. This is the shape to use when you want to wire a store value straight into a form.
Inside set you overwrite the underlying values. A computed has nowhere of its own to hold a value, so you decide what a write should update.
This example assumes "two words separated by a space". Type just Taro and lastName becomes empty, so the getter returns Taro with a trailing space, and what is displayed no longer matches what was typed. In a real form you would validate the shape of the input inside set, or split the given and family names into separate fields. A writable computed lets you interpose a conversion, but if that conversion is not reversible, the display drifts from the input.
watch — reacting to change
watch calls a callback when what it observes changes. It is not for using the return value; it is machinery for causing side effects.
<script setup lang="ts">
import { ref, watch } from 'vue'
const userId = ref(1)
const userName = ref('')
const fetchUser = async (id: number) => {
const res = await fetch(`/api/users/${id}`)
const user = (await res.json()) as { name: string }
userName.value = user.name
}
// refetch every time userId changes
watch(userId, (newId, oldId) => {
console.log(`from ${oldId} to ${newId}`)
void fetchUser(newId)
})
</script>
<template>
<p>{{ userName }}</p>
<button @click="userId += 1">Next user</button>
</template>
What can be watched
What you watch can be a ref, a getter function, a reactive object, or an array of those.
import { reactive, ref, watch } from 'vue'
const count = ref(0)
const state = reactive({ nested: { value: 0 } })
// a ref as-is
watch(count, (value) => console.log(value))
// a getter to narrow to one specific property
watch(
() => state.nested.value,
(value) => console.log(value),
)
// an array to watch several at once
watch([count, () => state.nested.value], ([c, v]) => console.log(c, v))
Use a getter when you want to watch a single property. Writing watch(state.nested.value, ...) passes the number itself, which cannot be watched.
Passing a reactive object directly makes it deep
const state = reactive({ nested: { count: 0 } })
// implicitly becomes a deep watcher, firing on nested changes too
watch(state, (newValue, oldValue) => {
// newValue === oldValue holds
})
Pass a reactive object directly and Vue watches deeply of its own accord. Overwriting a nested property fires it.
There is a trap here. In this case newValue and oldValue point at the same object. Both are the same Proxy, so they are useless for comparing against the value before the change.
To compare before and after, use a getter that reaches down to a primitive value. A getter returning an object, such as () => state.nested, does not fire on nested changes because the reference does not change. If you want to compare the contents of an object, keep a copy yourself inside the callback.
The main options
| Option | Details | Default |
|---|---|---|
immediate | Also call the callback on creation | false |
deep | Force deep watching. A number sets the maximum depth | false |
flush | Change when it is called ('pre' / 'post' / 'sync') | 'pre' |
once | Call once and then stop automatically | false |
onTrack / onTrigger | Receive dependency tracking and triggering for debugging | None |
flush defaults to 'pre', which calls before the component updates. Use 'post' when you want to read the state after the DOM has updated.
watch(
count,
() => {
// the DOM has already updated at this point
console.log(document.querySelector('.count')?.textContent)
},
{ flush: 'post' },
)
Stopping and pausing
The return value of watch carries methods for controlling it.
const handle = watch(count, () => console.log('changed'))
handle.pause() // temporarily stop it being called
handle.resume() // resume
handle.stop() // stop it entirely
A watcher tied to a component stops automatically when that component is destroyed. Whether it is tied depends on where you created it.
What gets tied is anything created during synchronous execution while the component context is active. That covers the run of setup and the time a lifecycle hook's callback is running synchronously.
The top level of script setup stays tied even across an await. The compiler saves and restores the context around the await. That restoration is specific to script setup; a hand-written async setup() does not have it.
A watcher created after the synchronous run has ended is not tied, so you stop it yourself. That covers crossing an await inside a lifecycle hook, and creating one after an await in a hand-written async setup().
onMounted(async () => {
await nextTick()
// not tied to this component; keeps running after unmount
const handle = watch(count, () => console.log('changed'))
})
Vue restores the component context when it runs a hook, but that only holds while the hook's callback runs synchronously. The first await ends that stretch and the context is detached. The compiler's restoration at the top level of script setup does not reach inside a hook.
onWatcherCleanup(), covered later in this chapter, also has a "call it synchronously" constraint, but it is looking for something different. That one looks for the running watcher; this one looks for the active component. That is why onWatcherCleanup() cannot be registered outside a watcher's callback, even inside a component.
When you want to stop watching once a condition is met, you also call handle.stop() yourself.
watchEffect — picking up dependencies automatically
With watchEffect you do not write what to watch. Whatever reactive values the callback reads become its dependencies.
import { ref, watchEffect } from 'vue'
const count = ref(0)
const message = ref('hello')
// it reads both count and message, so a change to either re-runs it
watchEffect(() => {
console.log(`${message.value}: ${count.value}`)
})
The main differences from watch are these.
watch | watchEffect | |
|---|---|---|
| What is watched | Written explicitly | Decided automatically from the values read in the callback |
| First run | Does not run by default (changeable with immediate) | Always runs |
| Previous value | Available | Not available |
Not having to manage dependencies is convenient, but it makes it harder to read off which values it reacts to. Read a value inside a conditional and the dependencies change with the branch taken. When you want what is watched to be explicit, choose watch.
Cleaning up asynchronous work
When what you watch changes repeatedly in quick succession, the next run starts before the previous finishes. Register cleanup so a stale result does not overwrite newer state.
import { onWatcherCleanup, ref, watch } from 'vue'
const userId = ref(1)
watch(userId, async (newId) => {
const controller = new AbortController()
// called before the next run starts, or when the component is destroyed
onWatcherCleanup(() => controller.abort())
const res = await fetch(`/api/users/${newId}`, { signal: controller.signal })
console.log(await res.json())
})
onWatcherCleanup() arrived in Vue 3.5. The example above calls it before the await, and that is deliberate. This function has a constraint on where it can be called.
It implicitly looks for the running watcher and ties itself to it, so after an await it cannot find its target. The development build warns, and nothing is registered.
// a form that does not register
watch(userId, async (newId) => {
const res = await fetch(`/api/users/${newId}`)
// at this point the target watcher is unknown
onWatcherCleanup(() => console.log('not registered'))
})
To register after an await, use the callback's third parameter. That one has its target watcher tied from the start, so it can register partway through asynchronous work.
watch(userId, async (newId, oldId, onCleanup) => {
const res = await fetch(`/api/users/${newId}`)
const controller = new AbortController()
onCleanup(() => controller.abort())
})
In watchEffect it arrives as the first parameter.
watchEffect((onCleanup) => {
const timer = setInterval(() => console.log('tick'), 1000)
onCleanup(() => clearInterval(timer))
})
Registered cleanup is called, by either route, immediately before the next run and when the watcher stops. What differs is when you can register it. onWatcherCleanup() works only during the synchronously executing stretch; the function passed as a parameter works anywhere inside the callback.
This constraint is a separate matter from the top-level await in script setup. There the compiler restores the context, whereas what onWatcherCleanup() looks for is the running watcher, and that is lost once you await inside the callback.
Which to use
The decision splits on "do I want a result, or do I want something to happen?"
| What you want to do | What to use |
|---|---|
| Derive another value from state and use it in the template | computed |
| Interpose a conversion between a form and state | Writable computed |
| Call an API or log something when a value changes | watch |
| Compare against the previous value | watch |
| React to several values without enumerating dependencies | watchEffect |
| Always run, including the first time | watchEffect, or watch with immediate |
Do not cause side effects inside a computed. Caching makes the number of runs unpredictable, and there is no guarantee about when it runs. If you need a side effect, move it to watch or watchEffect.
Summary
computedcaches based on its reactive dependencies. While they are unchanged, any number of reads costs one calculation- A method runs on every re-render. The difference shows when the calculation is expensive
- Non-reactive values such as
Date.now()are not recorded as dependencies - Passing
getandsetgives a writablecomputed, which pairs withv-model watchobserves refs, getters, reactive objects, and arrays of those. Use a getter to narrow to a property- Passing a reactive object directly makes it implicitly deep, and
newValueandoldValuepoint at the same object. To compare with the previous value, use a getter that reaches down to a primitive. A getter returning an object does not fire on nested changes deeptakes not just a boolean but a number.flush: 'post'calls after the DOM has updated- The return value of
watchhasstop/pause/resume. One tied to a component stops automatically on destruction. What gets tied is anything created during synchronous execution while the context is active, and the top level ofscript setupcounts even across anawait - A watcher created after the synchronous run has ended is not tied. That means crossing an
awaitinside a hook, or creating one after anawaitin a hand-writtenasync setup(). It keeps running after unmount, so callstop()yourself - The "call it synchronously" constraint on
onWatcherCleanup()is a separate matter. They look for different things: this one for the active component, that one for the running watcher - A writable
computedlets you interpose a conversion, but if the conversion is not reversible, the display drifts from the input watchEffectalways runs the first time, and its dependencies come from the values read in the callback. The previous value is not available- Cleanup can be registered through
onWatcherCleanup()(3.5 and later) or the callback parameter, and it is called immediately before the next run and on stop. ButonWatcherCleanup()can only register during the synchronously executing stretch. After anawait, use the callback parameter
- Vue — Computed Properties — caching, contrasted with methods
- Vue — Watchers — the shapes of watch sources and the implicit deep behaviour
- Vue — Reactivity API: Core — the options on
watchand theWatchHandlespecification - React guide — useEffect — comparison with a design where you write the dependency array yourself
What to read next
- script setup and the component contract — declaring the parent-child boundary as types
- Composables — extracting
computedandwatchtogether