DEV Community

A0mineTV
A0mineTV

Posted on

Vue Watchers Explained: When to Use watch (and When Not To)

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:

  • computed for derived values (pure, no side effects)
  • watch for side effects (imperative work triggered by reactive changes)
  • watchEffect for 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  • router.replace avoids polluting browser history.
  • setting undefined removes 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>
Enter fullscreen mode Exit fullscreen mode

watch vs watchEffect (and when to choose which)

watch(source, callback)

Use it when:

  • you want explicit dependencies
  • you need access to newValue and oldValue
  • you need options like deep or immediate

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);
});
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

deep: true (use sparingly)

Watch nested changes inside objects/arrays.

watch(filters, () => {
  // filters changed anywhere
}, { deep: true });
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Is this a side effect? (API, URL, storage, subscriptions) → watch
  2. Is it a derived value? (formatting, filtering for display, totals) → computed
  3. 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.

  • computed keeps your data transformations clean
  • watch keeps 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 useDebouncedRef composable for cleaner debounced watchers

Top comments (0)