Updated June 2026. Tested on Vue 3 with <script setup>. Part of the Techalyst Vue 3 series.

Watchers run a side effect when reactive data changes: fetch on a query change, validate on input, sync to storage. Vue 3 gives you watch and watchEffect, and once you hold one mental model the rest falls out of it.

The one mental model

If a watcher receives a reactive proxy directly, mutations inside it are visible without deep. If it receives a value or a getter returning a reference, only replacing that reference triggers it.

Hold that, and every rule below is just a consequence.

watch: explicit source and handler

watch(source, handler) runs the handler with the new and old value when the source changes. The source can be a ref, a reactive object, a getter, or an array of these.

import { ref, reactive, watch } from 'vue'

const city = ref('London')

// ref: Vue unwraps it, the handler gets the primitive
watch(city, (newVal, oldVal) => {
  fetchWeather(newVal)
})

Pass a reactive object directly and mutations inside it are seen for free, no deep needed.

const car = reactive({ brand: 'Toyota' })

watch(car, () => { /* runs on car.brand = 'Honda' */ })

But the moment you wrap it in a getter, you are back to watching a reference, so a mutation inside it will not fire unless you ask for deep, or you snapshot it with a spread.

const list = reactive([1, 2, 3])

watch(() => list, handler, { deep: true })   // getter: needs deep
watch(() => [...list], handler)              // spread: makes a new value each time
watch(() => list[0], handler)                // a specific element
watch(() => car.brand, handler)              // a specific property

deep, immediate, and flush

Three options cover most needs.

  • deep: true walks nested structures so inner mutations trigger the handler. You need it when watching a property-path getter, not when watching a reactive object directly.
  • immediate: true runs the handler once on setup, with oldVal undefined, useful for "load now and on every change after".
  • flush controls timing: 'pre' (default) runs before the DOM updates, 'post' runs after (so the DOM and template refs are up to date), 'sync' runs synchronously and is best avoided.
watch(city, handler, { immediate: true, flush: 'post' })

watchEffect: auto-tracked, with cleanup

watchEffect takes a single function, runs it immediately, and automatically tracks whatever reactive values it reads. No source list to maintain.

import { watchEffect } from 'vue'

watchEffect(() => {
  console.log(city.value)   // tracked automatically
  console.log(car.brand)    // tracked automatically
})

Its superpower is cleanup that runs before the next run. Register it with the onCleanup argument (or onInvalidate in older docs). The order is the key: on a change, cleanup fires first, then the effect re-runs.

data changes
   cleanup runs   (tear down the previous effect)
   effect runs    (set up the new one)

Only flush is a valid option for watchEffect; immediate and deep do not apply (it always runs immediately and tracks what it reads).

Two traps

newVal equals oldVal when deep-watching a reactive object. Both point at the same proxy, so they are identical. If you need distinct snapshots, watch a spread getter instead.

watch(list, (n, o) => { n === o })          // true, same proxy
watch(() => [...list], (n, o) => { n === o }) // false, separate arrays

The orphaned watcher. Reassigning the watched variable silently severs the watcher, with no error.

// bad: reassigning orphans the watcher
city = ref('Austin')

// good: mutate, never reassign
city.value = 'Austin'
list.push('a')
car.brand = 'Honda'

A real pattern: cancel the previous request

watchEffect's cleanup-before-rerun ordering is perfect for cancelling an in-flight request when the input changes again before it finishes. Here it is with a modern AbortController (the old axios.CancelToken is deprecated).

<script setup>
import { ref, watchEffect } from 'vue'
import axios from 'axios'

const city = ref('London')
const description = ref('')

watchEffect((onCleanup) => {
  const controller = new AbortController()

  // cleanup runs before the next effect, cancelling the previous request
  onCleanup(() => controller.abort())

  description.value = 'loading...'

  axios.get(`/api/weather/${city.value}`, { signal: controller.signal })
    .then((res) => { description.value = res.data.description })
    .catch((err) => { if (!axios.isCancel(err)) throw err })
})
</script>

Change city and the sequence is: cleanup aborts the old request, then the effect starts a fresh one. Click through five cities quickly and only the last request survives; the rest are aborted by their own cleanups. Aborting an already-finished request is harmless, so there is nothing extra to guard.

Which one to reach for

  • A specific source, and you want the old value or fine control (deep, immediate) → watch.
  • A side effect that should run now and whenever any value it reads changes, especially with setup/teardown → watchEffect.

Wrapping up

Watchers come down to one rule: a reactive object passed directly is seen deeply for free, anything behind a getter is watched by reference and needs deep or a spread snapshot. Use watch for an explicit source with old/new values, watchEffect for auto-tracked effects with cleanup, never reassign a watched variable, and lean on watchEffect's cleanup ordering for things like cancelling superseded requests.

More in the series: computed properties and the setup function. Questions welcome below.