Watchers and Effects
Computed properties handle derived state, but sometimes you need to do something when state changes: hit an API, update the document title, sync to localStorage, log analytics. That is what watchers are for. Vue gives you watch, watchEffect, and a few variants, each suited to different situations.
The key mental model: computed is for deriving values, watchers are for performing side effects.
watch: Explicit Source, Explicit Callback
watch takes a source (what to observe) and a callback (what to do when it changes). The callback receives the new and old values:
<script setup lang="ts">
import { ref, watch } from 'vue'
const searchQuery = ref('')
const results = ref<string[]>([])
const loading = ref(false)
watch(searchQuery, async (newQuery, oldQuery) => {
if (newQuery.length < 3) {
results.value = []
return
}
loading.value = true
const res = await fetch(`/api/search?q=${encodeURIComponent(newQuery)}`)
results.value = await res.json()
loading.value = false
})
</script>
The source can be a ref, a getter function, or an array of sources:
import { ref, watch } from 'vue'
const page = ref(1)
const sortBy = ref('name')
const filters = ref({ status: 'active' })
// Watch a single ref
watch(page, (newPage) => {
fetchData(newPage)
})
// Watch a getter (useful for reactive object properties)
watch(
() => filters.value.status,
(newStatus) => {
console.log('Status filter changed to', newStatus)
}
)
// Watch multiple sources
watch([page, sortBy], ([newPage, newSort], [oldPage, oldSort]) => {
fetchData(newPage, newSort)
})
Watching Reactive Objects
When you watch a reactive object or a ref containing an object, Vue performs a deep watch by default for reactive but NOT for ref. This catches people off guard:
import { ref, reactive, watch } from 'vue'
const stateA = reactive({ count: 0 })
// Deep watch by default -- this fires when count changes
watch(stateA, () => {
console.log('stateA changed')
})
const stateB = ref({ count: 0 })
// This does NOT fire when stateB.value.count changes
watch(stateB, () => {
console.log('stateB changed')
})
// You need { deep: true } or a getter
watch(stateB, () => { console.log('stateB deep') }, { deep: true })
watch(() => stateB.value.count, (n) => { console.log('count is', n) })
Use a getter when you only care about a specific property. Deep watches on large objects are expensive because Vue must traverse the entire object to detect changes.
watchEffect: Automatic Dependency Tracking
watchEffect runs its callback immediately and re-runs it whenever any reactive dependency accessed inside the callback changes. You do not specify a source -- Vue figures it out:
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const userData = ref<{ name: string; email: string } | null>(null)
watchEffect(async () => {
const res = await fetch(`/api/users/${userId.value}`)
userData.value = await res.json()
})
</script>
When userId changes, the effect re-runs automatically. You did not have to list userId as a source.
watch vs watchEffect
Use watch when:
- You need the old value for comparison.
- You want to be explicit about what triggers the effect.
- You want lazy execution (do not run on mount).
Use watchEffect when:
- You have multiple dependencies and listing them all is tedious.
- You want the effect to run immediately on setup.
- The dependencies are obvious from the code.
In practice, watch is more common because it makes intent clear. watchEffect is convenient for simple cases like syncing a title or updating a CSS variable, but it can trigger unexpectedly if your callback reads reactive values you did not intend to track.
Flush Timing
By default, watchers run before Vue updates the DOM. This means if you read DOM state inside a watcher, you see the previous render. Vue provides three flush options:
import { ref, watch, watchPostEffect } from 'vue'
const items = ref<string[]>([])
const listEl = ref<HTMLElement | null>(null)
// 'pre' (default): runs before DOM update
watch(items, () => {
// listEl.value still shows the old items
}, { flush: 'pre' })
// 'post': runs after DOM update
watch(items, () => {
// listEl.value now reflects the new items
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight
}
}, { flush: 'post' })
// 'sync': runs synchronously, immediately when the value changes
// Almost never what you want -- use only for debugging
watch(items, () => {
console.log('items changed synchronously')
}, { flush: 'sync' })
Vue provides watchPostEffect as shorthand for watchEffect with { flush: 'post' }:
import { ref, watchPostEffect } from 'vue'
const message = ref('')
const inputEl = ref<HTMLInputElement | null>(null)
// Runs after DOM update, so inputEl reflects current state
watchPostEffect(() => {
if (message.value && inputEl.value) {
inputEl.value.focus()
}
})
Use flush: 'post' whenever your effect needs to read from or interact with the updated DOM. Scroll-to-bottom, focus management, and third-party library integration (like chart updates) typically need post-flush.
Stopping Watchers
Watchers created in setup are automatically stopped when the component unmounts. But sometimes you need to stop one manually:
import { ref, watchEffect } from 'vue'
const data = ref<string | null>(null)
const stop = watchEffect(() => {
if (data.value !== null) {
console.log('Data loaded:', data.value)
stop() // Stop watching after first load
}
})
This pattern is useful for one-time effects: watch for a condition, act on it, then clean up.
Cleanup Inside Effects
If your effect starts an async operation, you may need to cancel it when the effect re-runs or the component unmounts. Both watch and watchEffect provide a cleanup function:
import { ref, watch } from 'vue'
const query = ref('')
watch(query, (newQuery, _oldQuery, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
fetch(`/api/search?q=${newQuery}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
results.value = data
})
.catch(err => {
if (err.name !== 'AbortError') throw err
})
})
The cleanup runs before the next invocation of the callback and when the watcher stops. This prevents race conditions where a slow request for "hel" returns after a fast request for "hello", overwriting the correct results.
For watchEffect, the cleanup parameter is passed to the effect function itself:
watchEffect((onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
fetch(`/api/data/${id.value}`, { signal: controller.signal })
.then(res => res.json())
.then(data => { result.value = data })
.catch(() => {})
})
Immediate and Once
Two useful options for watch:
// immediate: run the callback right away with the current value
watch(searchQuery, (query) => {
fetchResults(query)
}, { immediate: true })
// once: run the callback only the first time the source changes (Vue 3.4+)
watch(authToken, (token) => {
initializeSDK(token)
}, { once: true })
immediate: true is particularly useful when you need to load initial data based on a prop or route param. Without it, the watcher only fires on the second value.
Debouncing and Throttling
Vue does not have built-in debounce for watchers. You handle it yourself, which is straightforward:
import { ref, watch } from 'vue'
const searchQuery = ref('')
const results = ref<string[]>([])
let timeout: ReturnType<typeof setTimeout>
watch(searchQuery, (query) => {
clearTimeout(timeout)
timeout = setTimeout(async () => {
if (query.length < 2) return
const res = await fetch(`/api/search?q=${query}`)
results.value = await res.json()
}, 300)
})
Or extract it into a composable (covered in the composables subtopic). The point is that debouncing is a concern of your watcher logic, not something baked into the reactivity system.
Common Pitfalls
- Using a watcher when computed would suffice: If you are watching
aandbjust to setc = a + b, use a computed. Watchers are for side effects, not derived state. - Forgetting onCleanup with async watchers: Without cleanup, rapid changes cause race conditions. The last request to resolve wins, which may not be the latest request.
- Deep watching large objects:
{ deep: true }on a ref containing a large object traverses the entire structure on every change. Use a getter to watch specific properties instead. - watchEffect reading unintended dependencies: If your
watchEffectcallback reads a ref incidentally (say, for logging), it will re-run when that ref changes. Usewatchwith explicit sources if this is a problem. - Creating watchers outside setup: Watchers created inside
setTimeoutorasynccallbacks are not bound to the component lifecycle. They will not auto-stop on unmount. Always store the stop handle and call it inonUnmounted. - Using
flush: 'sync'in production: Sync watchers bypass Vue's batching and can cause cascading updates. They exist mainly for edge-case debugging.
Key Takeaways
watchtakes explicit sources and a callback. Use it when you know exactly what to observe and need access to old/new values.watchEffectauto-tracks dependencies and runs immediately. Use it for simple effects with obvious dependencies.- Use
flush: 'post'orwatchPostEffectwhen your effect needs the updated DOM. - Always use
onCleanupin async watchers to abort stale requests and prevent race conditions. - Watchers are for side effects. If you are computing derived state, use
computedinstead. - Watchers created in
setupauto-stop on unmount. Watchers created elsewhere need manual cleanup.