Vue is reactive by default. Most of the time, you write state, bind it to the template, and your UI updates automatically.
So why do we need watchers ?
Because sometimes you don’t want to render something based on state — you want to do something when state changes.
That “do something” is usually a side effect:
- call an API
- sync a query to the URL
- persist to localStorage
- reset a form
- trigger analytics
- run a debounce/throttle
That’s exactly where watch() shines.
The rule of thumb
Use:
-
computedfor derived values (pure, no side effects) -
watchfor side effects (imperative work triggered by reactive changes) -
watchEffectfor auto-tracking effects (when you don’t want to list dependencies explicitly)
If you remember only one line:
computed = “what”
watch = “do”
Why watchers matter in real projects
Watchers help you:
-
Isolate side effects from UI event handlers
(less spaghetti in
@click,@input, etc.) - Keep behavior predictable (“when X changes, do Y”)
- Avoid duplicated logic (one watcher handles all pathways that change the same state)
Example 1 — Debounced search (classic)
This is one of the most common real-world uses: fetch when input changes, but not on every keystroke.
<script setup>
import { ref, watch } from "vue";
const query = ref("");
const results = ref([]);
const loading = ref(false);
let timer;
watch(query, (newQuery) => {
clearTimeout(timer);
timer = setTimeout(async () => {
const q = newQuery.trim();
if (!q) {
results.value = [];
return;
}
loading.value = true;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
results.value = await res.json();
} finally {
loading.value = false;
}
}, 300);
});
</script>
<template>
<div>
<input v-model="query" placeholder="Search..." />
<p v-if="loading">Loading...</p>
<ul>
<li v-for="item in results" :key="item.id">{{ item.title }}</li>
</ul>
</div>
</template>
Why watch is the right tool here
This is not a derived value. You’re not computing UI — you’re triggering an external effect (a network request).
Example 2 — Cleanup with onCleanup (avoid race conditions)
If the user types quickly, you can end up with multiple requests in flight. Watchers can help you cancel the previous run.
With watch, you get a third argument: onCleanup.
<script setup>
import { ref, watch } from "vue";
const query = ref("");
const results = ref([]);
const loading = ref(false);
watch(query, async (q, _prev, onCleanup) => {
const trimmed = q.trim();
if (!trimmed) {
results.value = [];
return;
}
const controller = new AbortController();
onCleanup(() => controller.abort());
loading.value = true;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`, {
signal: controller.signal,
});
results.value = await res.json();
} catch (e) {
// Ignore abort errors; handle real errors if you want
} finally {
loading.value = false;
}
});
</script>
This pattern is great for:
- API requests
- timeouts
- event listeners
- subscriptions
Example 3 — Sync state to the URL (query params)
A practical pattern in dashboards: keep filters in sync with the URL so the user can refresh/share the link.
If you use Vue Router:
<script setup>
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const query = ref(route.query.q ?? "");
watch(query, (q) => {
router.replace({
query: { ...route.query, q: q || undefined },
});
});
</script>
-
router.replaceavoids polluting browser history. - setting
undefinedremoves the query parameter cleanly.
Example 4 — Persist preferences (localStorage)
Another common use: save a preference automatically.
<script setup>
import { ref, watch } from "vue";
const theme = ref(localStorage.getItem("theme") || "dark");
watch(theme, (value) => {
localStorage.setItem("theme", value);
});
</script>
watch vs watchEffect (and when to choose which)
watch(source, callback)
Use it when:
- you want explicit dependencies
- you need access to
newValueandoldValue - you need options like
deeporimmediate
watchEffect(callback)
Use it when:
- you want Vue to auto-track dependencies
- you’re fine with “run now, then re-run when anything used inside changes”
Example:
import { ref, watchEffect } from "vue";
const query = ref("");
watchEffect(() => {
console.log("query changed:", query.value);
});
Note: watchEffect runs immediately by default.
Common options you’ll actually use
immediate: true
Run the watcher once on setup.
watch(query, (q) => {
// do something
}, { immediate: true });
deep: true (use sparingly)
Watch nested changes inside objects/arrays.
watch(filters, () => {
// filters changed anywhere
}, { deep: true });
If you can, prefer watching specific fields instead of deep: true for performance and clarity.
Watch multiple sources
Great for “derived side effects”.
watch([query, page], ([q, p]) => {
// fetch based on both
});
Common mistakes (and how to avoid them)
❌ Using watch to compute values
If the output is purely derived from other state, use computed.
❌ Creating infinite loops
If your watcher updates the same state it watches, you can loop.
Fix by:
- writing to a different ref
- adding a guard
- moving logic into computed/event handlers
❌ Watching everything with deep: true
It’s tempting, but it can hide complexity and slow things down. Prefer targeted watchers.
Quick mental checklist
Before writing a watcher, ask yourself:
-
Is this a side effect?
(API, URL, storage, subscriptions) →
watch -
Is it a derived value?
(formatting, filtering for display, totals) →
computed -
Do I want auto dependency tracking?
→
watchEffect
Conclusion
Vue’s reactivity is already doing a lot for you — but watchers are the bridge between reactive state and real-world effects.
-
computedkeeps your data transformations clean -
watchkeeps side effects explicit and predictable
If you want, I can write a follow-up with:
-
flush: "post"and DOM-timing examples - an advanced dashboard filter example (pagination + sorting + URL sync)
- a
useDebouncedRefcomposable for cleaner debounced watchers
Top comments (0)