Chapter 16: Vue Watchers
Vue Watchers in Vue 3 (the modern Composition API way that’s standard in 2026). This is one of those tools that feels a bit “magic” at first but becomes your best friend for handling side effects when data changes.
Watchers let you react to changes in reactive data (ref, reactive, computed, props, etc.) by running some code (a “side effect”) — things like:
- Fetching data from an API when a search term changes
- Saving form data to localStorage when it updates
- Updating the page title or document favicon
- Logging analytics
- Syncing state between components or stores
Unlike computed (which returns a value and is cached/pure), watchers are imperative — they run code for its effects, not to produce a value.
In Vue 3 Composition API (<script setup>), we mainly use two functions:
- watch(source, callback, options?) → precise control
- watchEffect(callback, options?) → automatic & immediate
Let’s break it down step by step with real examples.
1. Basic watch() – Watching One or More Sources
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
<template> <div class="search-demo"> <input v-model="searchQuery" placeholder="Search movies..." @keyup.enter="search" /> <p v-if="loading">Searching...</p> <p v-else-if="results.length">Found {{ results.length }} movies</p> <ul v-else-if="searchQuery"> <li v-for="movie in results" :key="movie.id">{{ movie.title }}</li> </ul> <p v-else>No search yet</p> </div> </template> <script setup> import { ref, watch } from 'vue' const searchQuery = ref('') const results = ref([]) const loading = ref(false) watch(searchQuery, async (newQuery, oldQuery) => { if (newQuery.length < 3) { results.value = [] return } console.log(`Changed from "${oldQuery}" → "${newQuery}"`) loading.value = true try { // Fake API delay await new Promise(r => setTimeout(r, 800)) // Mock results results.value = [ { id: 1, title: `${newQuery} Avengers` }, { id: 2, title: `${newQuery} Spider-Man` }, { id: 3, title: `Best of ${newQuery}` } ] } catch (err) { console.error('Search failed', err) } finally { loading.value = false } }, { // Options (optional) debounce: 300, // wait 300ms after last change (needs plugin or manual impl in real code) immediate: false // default = false, doesn't run on mount }) </script> <style scoped> .search-demo { padding: 2rem; max-width: 600px; margin: auto; } input { width: 100%; padding: 0.8rem; font-size: 1.1rem; } ul { list-style: none; padding: 0; } li { padding: 0.6rem; border-bottom: 1px solid #eee; } </style> |
Key points about watch():
- Takes source first (ref / getter / array of them)
- Callback gets newValue, oldValue (and onCleanup function if needed)
- Lazy by default → doesn’t run immediately on mount
- Precise → only triggers when the watched source changes
- Can watch:
- Single ref → watch(count, …)
- Getter → watch(() => user.value.name, …)
- Reactive object property → watch(() => state.user.name, …)
- Array of sources → watch([count, name], ([newC, newN], [oldC, oldN]) => …)
2. watchEffect() – Automatic Dependency Tracking
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
<script setup> import { ref, watchEffect } from 'vue' const count = ref(0) const doubled = ref(0) watchEffect(() => { doubled.value = count.value * 2 console.log(`Effect ran → count: {count.value}, doubled: ${doubled.value}`) // Any ref/reactive accessed inside → becomes dependency }) // This effect runs immediately + on any dep change watchEffect(() => { document.title = `Count is ${count.value}` }) </script> |
Key differences from watch():
| Feature | watch(source, cb) | watchEffect(cb) |
|---|---|---|
| When it runs first time | Lazy (no, unless immediate: true) | Immediate (runs right away) |
| Dependencies | Explicit (you specify what to watch) | Automatic (whatever reactive is accessed) |
| Gets old/new value | Yes (new, old) | No |
| Best for | Precise control, need old value, one source | Side effects depending on many sources |
| Cleanup function | Yes (onCleanup in cb) | Yes (return cleanup fn from cb) |
| Common use | API calls on search change | Sync title, log, DOM updates |
3. Important Options for Both
|
0 1 2 3 4 5 6 7 8 9 10 11 |
watch(searchQuery, callback, { immediate: true, // run once immediately on setup deep: true, // watch nested properties in reactive objects flush: 'pre' | 'post' | 'sync', // when callback runs (default 'pre' before render) once: true // run only once }) |
- deep: true → useful but expensive for large objects → prefer getters for specific paths
- flush: ‘post’ → run after DOM update (good for DOM measurements)
4. Cleanup (Important for Async / Subscriptions)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
watch(searchQuery, (newQ, oldQ, onCleanup) => { const controller = new AbortController() fetch(`/api/search?q=${newQ}`, { signal: controller.signal }) .then(...) onCleanup(() => { controller.abort() // cancel previous request console.log('Cleaned up old search') }) }) |
Or in watchEffect:
|
0 1 2 3 4 5 6 7 8 9 |
watchEffect((onCleanup) => { const timer = setInterval(() => { ... }, 1000) onCleanup(() => clearInterval(timer)) }) |
5. Real-World Pattern: Debounced Search (Common in 2026)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import { watch, ref, nextTick } from 'vue' const query = ref('') const results = ref([]) let timeout = null watch(query, (newVal) => { clearTimeout(timeout) timeout = setTimeout(async () => { if (newVal.length < 2) { results.value = [] return } // fetch... }, 400) }) |
(Or use lodash.debounce / tiny-debounce composable in real apps)
Quick Summary Table – When to Choose What
| Goal | Use This | Why |
|---|---|---|
| Run side effect when specific ref changes | watch(ref, cb) | Precise, get old/new value |
| Need old value or immediate: false | watch(…) | — |
| Sync title / log / multiple deps side effect | watchEffect(cb) | Automatic deps, immediate |
| Cancel previous async on new change | watch + onCleanup | Explicit control |
| Watch nested property without deep:true | watch(() => obj.prop, …) | Better perf |
Pro Tips from Hyderabad 2026 Devs
- Prefer watch over watchEffect when possible → more explicit, easier to debug
- Avoid heavy logic in watchers → prefer computed for derived state
- Use composables to extract watcher logic (e.g. useDebouncedSearch())
- Watchers can cause infinite loops → be careful mutating watched source inside callback
- Vue Devtools shows watcher triggers beautifully
Practice challenge: Build a live currency converter:
- Watch baseAmount + fromCurrency → fetch rate → update result
- Use watchEffect to update document title with current rate
Any part unclear? Want full async search with abort + debounce example? Or watch vs computed deep comparison? Or how to watch props / route changes?
Just tell me — we’ll go deeper step by step 🚀
Happy watching (reactively) from Hyderabad! 💙
