Skip to content
Deep diveAdvanced Vue

computed or watch? A decision guide

Values worked out from other values belong in computed(). Side effects belong in a watcher, and a watcher that fetches can cancel its stale requests with Vue 3.5's onWatcherCleanup().

vuejs.school team4 Feb 20256 min readVue 3.5

Vue gives you two ways to respond to data that changes, computed() and watch(), and the usual mistakes with them rarely fail straight away. A watcher that copies one value into another ref shows the right text, but the copy can lag behind its source. A watcher that fetches search results works on a fast connection, then shows an old answer when a slow request finishes last.

The first should be a computed property. The second really is a watcher's job, but it has to cancel the request it no longer needs. One question tells you which tool you need.

Computed for derived values, watch for side effects

A computed property is for a value that can be worked out from other values. A watcher is for a side effect: something that happens when a value changes, such as fetching data, writing to localStorage, logging, or changing the document title.

The question is whether you could describe the thing as "X, worked out from Y". A full name worked out from a first and last name. A filtered list worked out from a list and a search term. A total worked out from prices. Those are computed. If instead you would describe it as "when Y changes, do Z", it is a watcher.

Why computed is cached and lazy

computed() takes a function and returns a read-only ref. Vue runs the function the first time something reads .value, records which reactive values it touched, and keeps the result. Reading it again does not run the function. Only when one of the recorded dependencies changes does the result become stale, and even then Vue does not recompute until the next read.

Two words carry that paragraph. Cached: a computed property that is read fifty times in a render costs one calculation. Lazy: a computed property that nothing reads never runs its function.

A filtered shopping list shows both. Every example in this guide is code for a component's <script setup lang="ts"> block.

import { computed, ref } from 'vue'

const items = ref([{ name: 'Milk', price: 1.2 }, { name: 'Eggs', price: 2.5 }])
const query = ref('')

const visible = computed(() =>
  items.value.filter(item => item.name.toLowerCase().includes(query.value.toLowerCase())),
)

visible is recalculated only when items or query changes, and only once something reads it again. The template can read it in three places and the filter runs once.

For that to hold, the function must be pure: it reads and returns, and it does not change other state, start a request or set a timer. A side effect hidden in a computed is hard to track down, because it runs only when Vue recomputes: on the first read after a dependency changes, and not at all while nothing reads the value.

Replace a copying watcher with computed

The mistake to search your code for is a watcher that exists only to copy values into another ref:

Profile.vueBreaks
import { ref, watch } from 'vue'

const firstName = ref('Asha')
const lastName = ref('Kumar')
const fullName = ref('')

watch([firstName, lastName], ([first, last]) => {
  fullName.value = `${first} ${last}`
}, { immediate: true })

It works, in the sense that fullName shows the right name. But fullName is now a second source of truth, and that has three costs:

  • It can fall behind. When a source changes, Vue queues the callback instead of running it on the spot, so code that sets firstName and reads fullName on the next line still gets the old name.
  • Anyone reading the code has to find the watcher to learn where the value comes from.
  • It needs immediate: true to be filled in at the start, which is a sign that what you wanted was a value, not an event.
Profile.vueWorks
import { computed, ref } from 'vue'

const firstName = ref('Asha')
const lastName = ref('Kumar')

const fullName = computed(() => `${firstName.value} ${lastName.value}`)

Three lines shorter, no immediate, and cached. Reading fullName after a change recomputes it, so it cannot fall behind, and the declaration says where the value comes from.

When watch is the right tool

A watcher earns its place when the change has to reach outside the reactive system. Saving a draft is a good example: the value lives in a ref, but the side effect, writing to storage, is not a value anybody reads.

import { ref, watch } from 'vue'

const draft = ref(localStorage.getItem('draft') ?? '')

watch(draft, (text) => {
  localStorage.setItem('draft', text)
})

watch() takes a source and a callback. The source can be a ref, a reactive object, a getter function such as () => props.id, or an array of those. The callback gets the new value and the old one.

By default the watcher is lazy: the callback runs only when the source changes, not on setup. Three options change when it runs:

  • { immediate: true } also runs it once at the start.
  • { once: true } runs it a single time, then stops watching.
  • { flush: 'post' } runs it after Vue has updated the component's own DOM, for effects that measure or scroll it. By default the callback runs before that update.

Choose between watchEffect and watch

watchEffect() takes a single function, runs it immediately, and re-runs it whenever any reactive value it read during that run changes. There is no separate source: the dependencies are whatever the function touched.

import { ref, watchEffect } from 'vue'

const page = ref(1)
const title = ref('Inbox')

watchEffect(() => {
  document.title = `${title.value} (page ${page.value})`
})

That is shorter than watch([page, title], ...) and it cannot fall out of sync when you add a third value. The cost is that the dependencies are implicit. A conditional branch that reads a value only sometimes tracks it only sometimes. Use watchEffect() for effects that read several values and need no old value. Use watch() when you want to name the source, need the previous value, or want the effect to be lazy.

Cancel stale requests with onWatcherCleanup

A watcher that fetches has a problem that does not show up until the network is slow. The user types a and a request goes out. They type ab and a second request goes out. The first one answers last, and the screen shows results for a under a search box that says ab.

Search.vueBreaks
import { ref, watch } from 'vue'

const query = ref('')
const results = ref<string[]>([])

watch(query, async (value) => {
  const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`)
  results.value = await response.json()
})

Nothing stops the old request from writing to results after the new one. onWatcherCleanup() fixes that: call it inside the callback with a function, and Vue runs that function the next time the watcher is about to run, or when the component unmounts.

Search.vueWorks
import { onWatcherCleanup, ref, watch } from 'vue'

const query = ref('')
const results = ref<string[]>([])

watch(query, async (value) => {
  const controller = new AbortController()
  onWatcherCleanup(() => controller.abort())

  const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
    signal: controller.signal,
  })
  results.value = await response.json()
})

An AbortController is the browser's way to cancel a request. When query changes again, the cleanup aborts the previous fetch, the aborted promise rejects, and the stale response never reaches results.

Vue reports that rejection, an AbortError, as an unhandled error in the watcher callback, so by default every cancelled search logs one to the console. To keep it quiet, wrap the two await lines in try/catch and ignore errors whose name is 'AbortError'.

One rule: onWatcherCleanup() must be called synchronously, before the first await. Vue uses the currently running watcher to know which effect the cleanup belongs to. After an await there is no current watcher, so Vue logs a warning (in development only) and the cleanup never runs.

Decide in four questions

Run through these in order and stop at the first match.

  1. Is it a value you display or pass on? computed().
  2. Does it only exist to copy something into another ref? computed(), and delete the ref.
  3. Does it talk to the outside world, such as the network, storage, the document, a timer? watch() or watchEffect().
  4. Does it start something that can still be running when the source changes again? Add onWatcherCleanup().
Check it stuck3 questions from the Watchers quiz.
Practice now
Was this helpful?

Keep reading