Vue.js: Watchers: watch
Last updated: 2026-10-20
Watchers (watch / watchEffect) let you observe changes in reactive data and run side effects when those changes happen. computed derives new values; watch triggers actions (such as sending a request or setting state).
watch is a core part of Vue's reactivity system, with 3 main scenarios: send an API request when data changes, debounce form input, and sync data across sources.
1. What You'll Learn
- The
watch()syntax and its 4 parts (source / callback / options / deep) - The 4 kinds of watch source: ref / reactive / computed / getter function
- How
watchEffect()tracks dependencies automatically, and how it differs from watch - The
immediate: trueoption deep: truefor watching nested properties- Implementing debounce and throttle
- Cleaning up side effects (onCleanup / component unmount)
2. A Search Box API Performance Problem
(2) The Pain: A Request per Keystroke, and the API Collapses
Alice implemented a product search box in the admin:
// ❌ The broken version: a request per character
const { ref } = Vue
const searchQuery = ref('')
watch(searchQuery, async (newValue) => {
// Send a request on every keystroke
const results = await fetch(`/api/search?q=${newValue}`)
searchResults.value = await results.json()
})
The API server collapses:
- The user types "iPhone 15" (8 characters) = 8 API calls
- 100 users typing at the same time = 800 API calls
- API rate limit: 100 per minute → the server crashes
Backend developer Charlie reports:
"Alice, your search is hammering the API. We need debouncing — wait until the user stops typing for 300ms, then send the request."
(2) The Vue watch + Debounce Solution
const { ref, watch } = Vue
const searchQuery = ref('')
const searchResults = ref([])
// Debounce: only request if the value stays unchanged for 300ms
let timeoutId = null
watch(searchQuery, (newValue) => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(async () => {
const response = await fetch(`/api/search?q=${newValue}`)
searchResults.value = await response.json()
}, 300)
})
(3) The Payoff
After adding debouncing:
- API calls: 8 per search → 1 (-87%)
- Server load: 800 → 100 (-87%)
- Response time: 50ms (cached) vs 500ms (live)
- Server stability: 99.9% (no more crashes)
3. watch() Basics
(1) The 4 Parameters
const { ref, watch } = Vue
const count = ref(0)
watch(
// 1. Source: the reactive data to watch
count,
// 2. Callback: (new value, old value) => {...}
(newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`)
},
// 3. Options (optional): immediate, deep, flush
{ immediate: true, deep: true }
)
(2) Watching a ref
const count = ref(0)
// Watch a ref
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`)
})
(3) Watching Multiple refs
const firstName = ref('Alice')
const lastName = ref('Smith')
// Watch several sources
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`Name: ${newFirst} ${newLast}`)
})
▶ Example: Watching a ref with Basic watch
▶ Try it YourselfOutput:
A number input starting at 0.
Changing it to 5 prints Count changed: 0 → 5 in the console and updates Count: 5 on the page.
Count: 0
Check the console for the change log
console: Count changed: 0 → 5
4. The 4 Kinds of Watch Source
(1) Watching a ref
const count = ref(0)
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`)
})
(2) Watching a reactive object
const { reactive, watch } = Vue
const user = reactive({ name: 'Alice', age: 25 })
// Watch the whole reactive object (deep automatically)
watch(user, (newVal, oldVal) => {
console.log('User changed:', newVal)
})
// ✅ Mutating a nested property also triggers it
user.name = 'Bob' // triggers
(3) Watching a getter function
const state = reactive({ user: { profile: { name: 'Alice' } } })
// Watch only a specific property (precise control)
watch(
() => state.user.profile.name,
(newVal) => {
console.log(`Name: ${newVal}`)
}
)
(4) Watching computed
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
watch(doubleCount, (newVal) => {
console.log(`Double: ${newVal}`)
})
(4) Comparing the 4 Sources
| Source | Syntax | Deep? | Good for |
|---|---|---|---|
| ref | watch(ref, fn) |
Shallow by default | A single ref |
| reactive | watch(reactive, fn) |
Deep automatically | Objects |
| getter | watch(() => obj.x, fn) |
Shallow by default | Specific properties |
| computed | watch(computed, fn) |
Shallow by default | Derived values |
▶ Example: Watching Deep Changes in a reactive Object
▶ Try it YourselfOutput:
Starts at Name: Alice, Age: 25, Theme: dark, Changes: 0.
Clicking Rename to Bob changes the name and Changes becomes 1;
clicking Switch to light changes the theme and Changes becomes 2.
Name: Bob
Age: 25
Theme: light
Changes: 2
5. watchEffect(): Automatic Dependency Tracking
(1) Basic Syntax
const { ref, watchEffect } = Vue
const count = ref(0)
const name = ref('Alice')
// No source to name: reactive data used in the body is tracked automatically
watchEffect(() => {
console.log(`count: ${count.value}, name: ${name.value}`)
// Reruns when count changes
// Also reruns when name changes
})
(2) watchEffect vs watch
| Dimension | watch | watchEffect |
|---|---|---|
| Source | Specified manually | Tracked automatically |
| Runs immediately | No by default (needs immediate: true) |
✅ Yes |
| Old and new values | ✅ Both | ❌ New value only |
| Side-effect cleanup | onCleanup | onCleanup |
| Best for | Precise watching | General side effects |
(3) Cleaning Up in watchEffect
const { watchEffect, onCleanup } = Vue
watchEffect((onCleanup) => {
// Set up timers, subscriptions, event listeners
const timer = setInterval(() => {
console.log('tick')
}, 1000)
// Cleanup: called before the next run or when the component unmounts
onCleanup(() => {
clearInterval(timer)
})
})
▶ Example: watchEffect Automatic Tracking
▶ Try it YourselfOutput:
effect runs: 1 on load.
Each click on count++ or Rename to Bob reruns the effect, so the counter goes 2, 3, 4… while the page shows the new count and name.
count: 1
name: Bob
effect runs: 3
6. Options: immediate / deep / flush
(1) immediate: Run Right Away
const count = ref(0)
// Default: only fires when count changes
watch(count, (newVal) => console.log(newVal)) // does not run immediately
// immediate: true: fire once right away
watch(count, (newVal) => console.log(newVal), { immediate: true })
// ✅ Runs immediately: 0 → 0
(2) deep: Watch Nested Properties
const { ref, watch } = Vue
const user = ref({ profile: { name: 'Alice' } })
// Default: only triggers when the ref's .value is replaced as a whole
watch(user, () => console.log('changed')) // ❌ user.value.profile.name = 'Bob' does not trigger
// deep: true: nested property changes trigger too
watch(user, () => console.log('changed'), { deep: true })
// ✅ user.value.profile.name = 'Bob' triggers
(3) flush: When the Callback Runs
// Default 'pre': fires before the component updates
watch(source, fn, { flush: 'pre' })
// 'post': fires after the component updates (the DOM is already updated)
watch(source, fn, { flush: 'post' })
// 'sync': fires synchronously (rarely used, poor performance)
watch(source, fn, { flush: 'sync' })
7. Complete Example: Search Box + Debounce
▶ Example: Debounced Search (CDN Simulation)
▶ Try it YourselfOutput:
Typing "iphone" quickly shows Recent query: (empty) at first;
300ms after you stop typing it becomes Recent query: iphone and Triggers increments by 1 instead of 6.
Recent query: iphone
Triggers: 1
Note: a real project would use fetch + a backend API; here setTimeout simulates the trigger logic.
(1) 📊 Quick Reference for the 5 Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Watching reactive without deep | Nested changes do not trigger | watch(obj, fn, { deep: true }) |
| Watching the whole ref-wrapped object | Nested changes do not trigger | Switch to reactive or add deep |
| Writing an infinite loop in watchEffect | The browser freezes | Do not mutate data the body reads |
| Never clearing timers | Memory leak | Use onCleanup |
| Confusing immediate | You expect an old value but get none | Use immediate + oldVal (may be undefined) |
❓ FAQ
.value. If the ref wraps an object, both values are the same ref.value reference unless the whole object is replaced. To watch nested properties, use reactive or a getter function.watch(ref, fn, { deep: true }); (2) watch(() => ref.value.x, fn) (a getter); (3) wrap the data with reactive instead.watch(() => props.count, fn). Remember that props are read-only, so never mutate them inside the callback.undefined (there is no "old value" on the first run). Read the previous value from the ref's initial value or from the store.(newVal) => { setTimeout(() => {...}, 0) }.📖 Summary
- watch observes changes in reactive data and takes 4 parts: source / callback / options / deep
- 4 kinds of source: ref / reactive / getter function / computed
- watchEffect tracks reactive dependencies inside its body automatically and runs once immediately
- immediate: true runs right away; deep: true watches nested properties
- Debounce: only the last trigger within 300ms runs; throttle: at most once per second
- onCleanup cleans up side effects: timers, subscriptions, event listeners
- watch vs watchEffect: watch gives precise control + old/new values; watchEffect tracks automatically + is simpler
📝 Exercises
-
Basic (⭐) Use watch to observe a ref called
count:- Print "count: ${old} → ${new}" when count changes
- Use
immediate: trueso the callback runs once right away
-
Intermediate (⭐⭐) Build an "autosave" feature with watch:
- Edit a ref text called
content - After 1 second, call
save(content)automatically (debounce) - Clear the timer when navigating away (component unmount)
- Edit a ref text called
-
Challenge (⭐⭐⭐) Build a complete search + autocomplete + history system:
- Debounced search (300ms)
- Show the result list (v-for)
- Keep the 5 most recent searches
- Click a history item to search again
- Cancel pending requests when the query changes
- Implement it with watchEffect + onCleanup