On this page
computed(), watch(), and watchEffect(): derived state and side effects
Derived state with computed()
In real applications, you frequently need values that are derived from other state — a filtered list, a formatted string, a total sum. While you could use a method and call it in the template, computed() is the correct tool for this job.
computed() returns a readonly reactive reference whose value is recalculated only when one of its reactive dependencies changes. Between changes, the result is cached — accessing the computed property hundreds of times in a template costs only one computation.
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, name: 'Apple', done: true },
{ id: 2, name: 'Banana', done: false },
{ id: 3, name: 'Cherry', done: true },
])
const doneCount = computed(() => items.value.filter(i => i.done).length)
const pendingCount = computed(() => items.value.length - doneCount.value)
const summary = computed(() => `${doneCount.value} done, ${pendingCount.value} pending`)Accessing summary.value in a component template renders "2 done, 1 pending". If you add an item to the array, Vue recomputes doneCount, pendingCount, and summary in one pass, then updates only the parts of the DOM that depend on each computed value.
Computed getter and setter
By default, computed properties are read-only. You can create a writable computed by providing both a get and set function:
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value: string) => {
const [first, ...rest] = value.split(' ')
firstName.value = first
lastName.value = rest.join(' ')
},
})
fullName.value = 'Grace Hopper'
// firstName.value === 'Grace'
// lastName.value === 'Hopper'Writable computeds are useful when you need v-model on a derived value.
Computed with TypeScript
TypeScript infers the return type automatically. Annotate explicitly when inference is not possible:
interface Product {
id: number
price: number
category: string
}
const products = ref<Product[]>([])
// Type inferred as ComputedRef<Product[]>
const electronics = computed(() =>
products.value.filter(p => p.category === 'electronics')
)watch() — reactive side effects
watch() lets you run a function in response to specific reactive sources changing. Unlike watchEffect, it is lazy (does not run on mount unless immediate: true) and gives you access to both the old and new values.
Watching a single source
const query = ref('')
watch(query, (newVal, oldVal) => {
console.log(`Search changed: "${oldVal}" → "${newVal}"`)
performSearch(newVal)
})Watching multiple sources
Pass an array to watch multiple sources at once. The callback receives arrays of new and old values in the same order:
const page = ref(1)
const filter = ref('all')
watch([page, filter], ([newPage, newFilter], [oldPage, oldFilter]) => {
loadData(newPage, newFilter)
})Watching reactive objects
To detect mutations inside a reactive object or a ref wrapping an object, use { deep: true }:
const user = ref({ name: 'Ada', prefs: { theme: 'dark' } })
watch(user, (newUser) => {
saveToServer(newUser)
}, { deep: true })
// Or watch a specific nested property with a getter function
watch(() => user.value.prefs.theme, (theme) => {
document.documentElement.setAttribute('data-theme', theme)
})Immediate execution
{ immediate: true } runs the callback immediately with the current value, before the first change:
watch(userId, fetchUserData, { immediate: true })
// Equivalent to: call fetchUserData(userId.value) on mount, then on each changeOnce — auto-stop after first change
{ once: true } (Vue 3.4+) removes the watcher automatically after it fires once:
watch(isReady, () => {
initializePlugin()
}, { once: true })Stopping a watcher
watch() returns a stop function. Call it to stop watching:
const stop = watch(source, callback)
// Later, when no longer needed:
stop()Watchers inside <script setup> are stopped automatically when the component unmounts. Manual stop is only needed for watchers created outside the component lifecycle (e.g., inside a setTimeout).
watchEffect() — automatic dependency tracking
watchEffect() runs a function immediately and automatically re-runs it whenever any reactive value accessed inside changes. You do not list dependencies — the tracking is implicit.
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const user = ref<User | null>(null)
watchEffect(async () => {
// userId.value is accessed, so this re-runs whenever userId changes
const response = await fetch(`/api/users/${userId.value}`)
user.value = await response.json()
})Cleanup with onCleanup
When watchEffect re-runs or the component unmounts, Vue calls an optional cleanup function you register with the onCleanup argument:
watchEffect((onCleanup) => {
const controller = new AbortController()
fetch(`/api/data?page=${page.value}`, { signal: controller.signal })
.then(res => res.json())
.then(data => { results.value = data })
onCleanup(() => controller.abort()) // Cancel on re-run or unmount
})Flush timing
By default, watchEffect runs before the component updates the DOM. Use { flush: 'post' } to run after the DOM update (useful for accessing updated DOM elements):
watchEffect(() => {
// Runs after the DOM update
console.log(listElement.value?.scrollHeight)
}, { flush: 'post' })watch vs watchEffect — when to use which
| Criterion | watch() | watchEffect() |
|---|---|---|
| Runs on mount | No (unless immediate) |
Yes |
| Old value access | Yes | No |
| Explicit sources | Yes | No (auto-tracked) |
| Lazy by default | Yes | No |
| Async operations | Manual cleanup | onCleanup argument |
| Best for | Reacting to specific changes | Syncing side effects |
Computed vs watch for derived values
A very common mistake is using watch to compute derived values:
// WRONG — needlessly imperative
watch(items, () => {
total.value = items.value.reduce((s, i) => s + i.price, 0)
}, { immediate: true })
// CORRECT — declarative, cached
const total = computed(() => items.value.reduce((s, i) => s + i.price, 0))Reserve watch for side effects — things with external consequences like API calls, localStorage writes, or DOM manipulation. Use computed for values.
Practice
- Chained computeds: Create a product list with
ref<Product[]>. Build three chained computeds:filteredProducts(by category),sortedProducts(by price), andpaginatedProducts(slice of 10). Notice how changing the filter invalidates all three. - Autosave with watch: Create a form reactive object. Use
watch(..., { deep: true })to save the form data tolocalStorage500ms after any change (debounce withsetTimeoutandonCleanupto cancel pending saves). - Data fetching with watchEffect: Fetch data from
https://jsonplaceholder.typicode.com/postsand filter by auserIdref. Use theonCleanupcallback withAbortControllerto cancel the in-flight request whenuserIdchanges.
In the next lesson, we will cover props and emits — how components communicate up and down the component tree.
<script setup lang="ts">
import { ref, computed, watch, watchEffect } from 'vue'
// --- computed() ---
const price = ref(100)
const quantity = ref(3)
const discount = ref(10) // percentage
// Derived value — cached until dependencies change
const subtotal = computed(() => price.value * quantity.value)
const total = computed(() => {
const discountAmount = subtotal.value * (discount.value / 100)
return subtotal.value - discountAmount
})
// Writable computed
const fullName = ref('Ada Lovelace')
const firstName = computed({
get: () => fullName.value.split(' ')[0],
set: (val: string) => {
const parts = fullName.value.split(' ')
parts[0] = val
fullName.value = parts.join(' ')
},
})
// --- watch() ---
// Runs when 'price' changes; receives new and old values
watch(price, (newPrice, oldPrice) => {
console.log(`Price changed from ${oldPrice} to ${newPrice}`)
})
// Watch multiple sources at once
watch([price, quantity], ([newP, newQ], [oldP, oldQ]) => {
console.log(`Order updated: ${newQ}x at $${newP}`)
})
// Deep watch — detect nested mutations
const settings = ref({ theme: 'dark', lang: 'en' })
watch(settings, (newSettings) => {
localStorage.setItem('settings', JSON.stringify(newSettings))
}, { deep: true })
// --- watchEffect() ---
// Runs immediately and re-runs when ANY accessed reactive source changes
const searchQuery = ref('')
const results = ref<string[]>([])
watchEffect(async () => {
if (!searchQuery.value) {
results.value = []
return
}
// All reactive deps inside are tracked automatically
const res = await fetch(`/api/search?q=${searchQuery.value}`)
results.value = await res.json()
})
</script>
<template>
<section>
<label>
Price: <input type="number" v-model.number="price" />
</label>
<label>
Quantity: <input type="number" v-model.number="quantity" />
</label>
<label>
Discount %: <input type="number" v-model.number="discount" />
</label>
<p>Subtotal: ${{ subtotal }}</p>
<p>Total after discount: ${{ total.toFixed(2) }}</p>
<label>
First name: <input v-model="firstName" />
</label>
<p>Full name: {{ fullName }}</p>
</section>
</template>
Sign in to track your progress