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: truewalks 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: trueruns the handler once on setup, witholdValundefined, useful for "load now and on every change after".flushcontrols 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.
All comments ()
No comments yet
Be the first to leave a comment on this post.