On this page

computed(), watch(), and watchEffect(): derived state and side effects

14 min read TextCh. 2 — Reactivity

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 change

Once — 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

  1. Chained computeds: Create a product list with ref<Product[]>. Build three chained computeds: filteredProducts (by category), sortedProducts (by price), and paginatedProducts (slice of 10). Notice how changing the filter invalidates all three.
  2. Autosave with watch: Create a form reactive object. Use watch(..., { deep: true }) to save the form data to localStorage 500ms after any change (debounce with setTimeout and onCleanup to cancel pending saves).
  3. Data fetching with watchEffect: Fetch data from https://jsonplaceholder.typicode.com/posts and filter by a userId ref. Use the onCleanup callback with AbortController to cancel the in-flight request when userId changes.

In the next lesson, we will cover props and emits — how components communicate up and down the component tree.

computed vs methods for derived values
Always prefer computed() over a method call in templates for derived values. computed() is cached and only re-runs when its dependencies change. A method call in the template runs on every render, even if nothing changed.
Avoid side effects inside computed()
computed() getters must be pure — no mutations, no async operations, no DOM access. Side effects belong in watch() or watchEffect(). Vue may call the getter multiple times for caching purposes.
watchEffect cleanup
watchEffect passes an onCleanup() function to handle teardown between runs. Use it to cancel pending requests or clear timers: watchEffect((onCleanup) => { const timer = setInterval(...); onCleanup(() => clearInterval(timer)) })
vue
<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>