Watchers
Computed properties calculate a value from state. But sometimes a change in state should do something: save a draft to the browser, load new data when a product id changes, or start a timer. Actions like these are called side effects, and Vue runs them with watchers.
In this lesson you will use watch() and watchEffect(), choose the right source to watch, use the immediate, deep and once options, clean up old work before a watcher runs again, and know when a computed property is the better choice.
watch(): react to a change
watch() takes a source to watch and a callback to run when the source changes. The callback receives the new value and the old value:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>watch</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
<style>button { margin-right: 8px; }</style>
</head>
<body>
<div id="app">
<h3>Shatabdi coach C2: {{ seats }} seats free</h3>
<button @click="seats--" :disabled="seats === 0">Book a seat</button>
<button @click="seats++">Cancel a booking</button>
<ul>
<li v-for="(line, i) in history" :key="i">{{ line }}</li>
</ul>
</div>
<script>
const { createApp, ref, watch } = Vue;
createApp({
setup() {
const seats = ref(3);
const history = ref([]);
watch(seats, (newSeats, oldSeats) => {
history.value.push(`Seats changed from ${oldSeats} to ${newSeats}`);
if (newSeats === 0) {
history.value.push("Coach is full. Show the waiting list.");
}
});
return { seats, history };
}
}).mount("#app");
</script>
</body>
</html>Book three seats and watch the history grow. The callback runs after each change, not when the page first loads.
What can you watch?
The first argument can be one of four kinds of source:
const city = ref("Pune");
const order = reactive({ item: "Thali", qty: 1 });
watch(city, (v) => {}); // 1. a ref (or computed)
watch(() => order.qty, (v) => {}); // 2. a getter function
watch(order, (v) => {}); // 3. a whole reactive object (deep)
watch([city, () => order.qty], ([c, q]) => {}); // 4. an array of sourcesA common mistake is watch(order.qty, ...). That passes the number 1, not something Vue can track, so Vue warns that it is not a valid source. When you want one property of a reactive object, wrap it in a getter: () => order.qty.
The immediate, deep and once options
A third argument holds options:
immediate: trueruns the callback once right away, then on every change. Useful when you load data for the starting value too.deep: truemakes the watcher fire when nested properties change. Watching a reactive object directly is deep automatically, but watching a ref that holds an object or a getter that returns one is not. Since Vue 3.5deepcan also be a number that limits how many levels deep Vue looks.once: true(Vue 3.4 and newer) runs the callback only for the first change and then stops.
The next example saves a cart to localStorage (the browser's small key-value store) whenever anything inside it changes:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>deep and immediate</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
</head>
<body>
<div id="app">
<p v-for="item in cart" :key="item.name">
{{ item.name }} x {{ item.qty }}
<button @click="item.qty++">+</button>
</p>
<p>Saved data: <code>{{ saved }}</code></p>
</div>
<script>
const { createApp, ref, watch } = Vue;
createApp({
setup() {
const cart = ref([
{ name: "Basmati rice 5 kg", qty: 1 },
{ name: "Toor dal 1 kg", qty: 2 }
]);
const saved = ref("");
watch(cart, (newCart) => {
const json = JSON.stringify(newCart);
try { localStorage.setItem("cart", json); } catch (e) { /* storage blocked */ }
saved.value = json;
}, { deep: true, immediate: true });
return { cart, saved };
}
}).mount("#app");
</script>
</body>
</html>Without deep: true, clicking "+" would not trigger the watcher: the ref still holds the same array, only a property inside it changed. Without immediate: true, "Saved data" would stay empty until the first click.
watchEffect(): automatic tracking
watchEffect() runs a function immediately and tracks every reactive value the function reads, just like a computed getter. Whenever any of them changes, it runs again. You do not list the sources yourself:
watchEffect(() => {
document.title = `${unread.value} new messages | Inbox`;
});watch() is more explicit: you choose exactly what triggers it, it does not run at start unless you ask, and it gives you the old value. watchEffect() is shorter when the effect uses several values and needs all of them from the start.
Cleaning up before the next run
Imagine a search box that waits until the user stops typing for 400 milliseconds before searching. Each keystroke must cancel the previous timer. Vue 3.5 added onWatcherCleanup(): the function you give it runs just before the watcher runs again (and when it stops).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cleanup</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
</head>
<body>
<div id="app">
<label>Search city: <input v-model="query"></label>
<p>{{ status }}</p>
<ul><li v-for="c in results" :key="c">{{ c }}</li></ul>
</div>
<script>
const { createApp, ref, watch, onWatcherCleanup } = Vue;
const cities = ["Jaipur", "Jalandhar", "Jammu", "Pune", "Patna", "Kochi", "Kolkata", "Lucknow"];
createApp({
setup() {
const query = ref("");
const results = ref([]);
const status = ref("Type to search");
watch(query, (text) => {
status.value = "Waiting for you to stop typing...";
const timer = setTimeout(() => {
results.value = cities.filter((c) => c.toLowerCase().startsWith(text.toLowerCase()));
status.value = `${results.value.length} match(es) for "${text}"`;
}, 400);
onWatcherCleanup(() => clearTimeout(timer));
});
return { query, results, status };
}
}).mount("#app");
</script>
</body>
</html>Type "Ja" quickly. Only one search happens, for the final text, because the first timer was cleared. This technique is called debouncing. The same pattern cancels an old network request with an AbortController, which you will use in Fetching Data.
onWatcherCleanup() must be called synchronously, before any await in the callback. On Vue versions before 3.5, use the third argument of the callback instead: watch(query, (text, old, onCleanup) => { onCleanup(() => clearTimeout(timer)); }).
Stopping a watcher and timing
watch() and watchEffect() return a function that stops the watcher. Watchers created during setup() stop automatically when the component is removed, so you only need this for watchers created later, for example inside a setTimeout:
const stop = watch(score, (s) => {
if (s >= 100) {
console.log("Century!");
stop(); // no need to keep watching
}
});By default a watcher callback runs before Vue updates the component's DOM. If your callback needs to read the updated DOM, pass { flush: "post" } (or use watchPostEffect()).
Watcher or computed?
| You want to... | Use |
|---|---|
| Show a value derived from other state | computed() |
| Save, fetch, log, start a timer or touch the DOM when state changes | watch() / watchEffect() |
| Keep two pieces of state "in sync" | Usually one state plus computed(), not two states and a watcher |
Common mistakes
- Watching a reactive property directly.
watch(order.qty, ...)does not work; writewatch(() => order.qty, ...). - Expecting deep changes on a ref. Watching a ref holding an array or object needs
{ deep: true }, or watch a getter for the field you need. - Using a watcher to compute a value. If the callback only assigns a derived value to another ref, a computed property is simpler and cannot go out of sync.
- Calling
onWatcherCleanup()afterawait. Register cleanup at the start of the callback.
Key points
watch(source, callback, options)runs side effects when the source changes, with new and old values.- Sources can be refs, computed refs, getter functions, reactive objects or arrays of these.
immediateruns it at start,deepnotices nested changes,onceruns it only for the first change.watchEffect()runs immediately and tracks whatever it reads automatically.onWatcherCleanup()(Vue 3.5) cancels old timers or requests before the next run.- Prefer
computed()for derived values; use watchers for side effects.
Exercise
Make a "study timer" note for Ananya. Bind a <textarea> to a ref called draft with v-model. Use a watcher to save the draft to localStorage under the key "study-note" 500 milliseconds after she stops typing (use onWatcherCleanup to clear the old timer), and show "Saved" or "Typing..." below the box. Load any saved draft when the app starts.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Study Note</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
</head>
<body>
<div id="app">
<textarea v-model="draft" rows="4" cols="40"></textarea>
<p>{{ status }}</p>
</div>
<script>
const { createApp, ref, watch, onWatcherCleanup } = Vue;
createApp({
setup() {
let start = "";
try { start = localStorage.getItem("study-note") ?? ""; } catch (e) {}
const draft = ref(start);
const status = ref("Saved");
watch(draft, (text) => {
status.value = "Typing...";
const timer = setTimeout(() => {
try { localStorage.setItem("study-note", text); } catch (e) {}
status.value = "Saved";
}, 500);
onWatcherCleanup(() => clearTimeout(timer));
});
return { draft, status };
}
}).mount("#app");
</script>
</body>
</html>Each keystroke clears the previous timer, so the note is written to storage only once she pauses. The try blocks keep the app working if the browser blocks storage.
Quick Quiz
1. How do you watch only the qty property of a reactive object order?
A getter function lets Vue track the property; order.qty alone is just a number.
2. A ref holds an array of objects. Which option makes the watcher fire when item.qty changes inside it?
Watching a ref only tracks .value replacement unless you add deep: true.
3. What is the main difference between watch() and watchEffect()?
watchEffect() collects dependencies automatically and runs at once; watch() is explicit and lazy by default.
4. What does onWatcherCleanup() help you do?
The cleanup function runs right before the next run, so stale work can be cancelled.
Learn Vue.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
