Vue Watchers Explained Properly: When to Use watch, watchEffect, and When You Should Use Neither

Immediate watchers, deep watching, watchEffect vs watch, stopping watchers manually, the async watcher that causes memory leaks, computed properties that do the job cleaner — the complete guide to Vue’s reactivity watchers with the specific situations where each one is the right choice and the anti-patterns that look right but break subtly.


The bug report says search results are showing stale data. QA can’t reproduce it reliably — it only happens sometimes, only for some users, only when they type fast. The code looks correct: a watch on the search query, an await for the API call, a ref assignment. It passed every manual test. In production, under real typing speed and real network jitter, two requests race and the older one wins, overwriting a newer, correct result with a stale one. Nobody touched this file in the postmortem — the watcher has looked the same since it was written six months ago.

This is the shape of most Vue watcher bugs: the code compiles, the happy path works, and the failure only shows up under timing conditions nobody tested for. This post covers watch (immediate, deep, multiple sources), watchEffect (and its dependency-tracking trap), the specific async pattern that causes the race condition above, how to stop watchers that Vue won’t clean up automatically, and — the part most watcher guides skip — the cases where a watcher is the wrong tool entirely and computed does the job with less code and no edge cases.


Why Watchers Get Reached for When computed() Was the Answer

Understanding the problem before the fixes:

// This is what a lot of Vue code looks like
const fullName = ref('')

watch([firstName, lastName], ([first, last]) => {
  fullName.value = `${first} ${last}`
})

This runs fine in the demo. The bug is invisible until someone checks fullName before firstName or lastName ever changes:

const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = ref('')

watch([firstName, lastName], ([first, last]) => {
  fullName.value = `${first} ${last}`
})

console.log(fullName.value)
// Result: '' — the watcher hasn't fired yet, nothing has changed

watch does not run on setup by default. It waits for the first change. fullName sits empty until some later action mutates firstName or lastName — which, on a profile page where the user never edits their name, might be never. The fix people reach for is { immediate: true }, which papers over the symptom but keeps the real problem: this is derived state being manually kept in sync with a side-effect mechanism.

// computed — no initial-value bug, no manual sync, no immediate flag needed
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

console.log(fullName.value)
// Result: 'Ada Lovelace' — correct immediately, cached, recomputed only on read after a dependency changes

The rule: if a watcher’s callback body is just “compute a new value and assign it to a ref,” delete the watcher. computed gives you correctness on the first render, automatic caching, and one line of code instead of four.


watch() — Explicit Sources, Old and New Values

watch tracks exactly what you tell it to track — nothing more. That’s the tradeoff against watchEffect, and it’s usually the right one: you know precisely what triggers the callback just by reading the source argument.

import { ref, watch } from 'vue'

const userId = ref(1)
const user = ref(null)

watch(userId, async (newId, oldId) => {
  console.log(`user changed: ${oldId} -> ${newId}`)
  user.value = await fetchUser(newId)
})

newValue and oldValue arrive as the first two arguments. This is watch‘s concrete advantage over watchEffect — if the logic depends on the direction of a change, not just that a change happened, watch is the only one of the two that hands you both values directly.

// Only refetch on an actual page increase, not a reset back to page 1
watch(currentPage, (newPage, oldPage) => {
  if (newPage > oldPage) {
    loadNextPage(newPage)
  }
})

Multiple Sources

watch([userId, filters], ([newId, newFilters], [oldId, oldFilters]) => {
  if (newId !== oldId) resetPagination()
  fetchResults(newId, newFilters)
})

immediate: true — What It’s Actually For

watch(userId, (newId, oldId) => {
  // oldId is undefined on this first, immediate run
  fetchUser(newId)
}, { immediate: true })

immediate: true is for one specific situation: the same logic has to run once on mount and again on every subsequent change. It is not a general substitute for “load data when the component mounts” — that’s what onMounted is for, and reaching for immediate: true instead usually means an unrelated mount-time action got bolted onto a watcher just to avoid one extra function call. If the mount behavior and the reactive-update behavior are conceptually different operations, keep them as two separate pieces of code even if they happen to call the same function.

deep: true — Measuring the Cost

const settings = ref({ theme: 'dark', notifications: { email: true, sms: false } })

watch(settings, (newVal) => {
  saveSettings(newVal)
}, { deep: true })

settings.value.notifications.email = false // fires — deep: true walked the nested object

Without deep: true, that mutation is invisible to the watcher — it only fires when settings.value itself is reassigned to a new object. With it, Vue recursively walks the object on every reactivity check to detect nested changes. On a small settings object that cost is nothing. On a state tree with hundreds of nested entries, it’s a measurable, recurring cost on every mutation anywhere in that tree — and it shows up as jank that’s hard to trace back to a single watch call three files away.

// ❌ Deep-watches the entire object for a change to one field
watch(settings, (newVal) => {
  saveSettings({ email: newVal.notifications.email })
}, { deep: true })

// ✅ Watch a getter — tracks exactly one path, no recursive walk
watch(
  () => settings.value.notifications.email,
  (newVal) => saveSettings({ email: newVal })
)

If the goal is “react to any change anywhere in this object,” and that’s a genuine requirement rather than a shortcut, switching the source to a reactive() object is usually cheaper than deep-watching a ref-wrapped one, since reactive state is deeply reactive by construction rather than requiring a recursive diff pass on every check.


watchEffect() — Automatic Dependency Tracking, and Its One Sharp Edge

watchEffect runs its function immediately, tracks every reactive value read during that run, and re-runs whenever any of them changes. No source array to maintain.

import { watchEffect } from 'vue'

watchEffect(() => {
  document.title = `${unreadCount.value} unread`
})

This is the right tool when an effect naturally reads several reactive values and you don’t need old/new comparison — it reads cleaner than listing every dependency in a watch array, and there’s no dependency array to forget to update.

The Bug: Tracking Stops at await

watchEffect(async () => {
  const id = userId.value       // read synchronously — tracked
  const data = await fetchUser(id)
  console.log(filters.value)    // read after await — NOT tracked
})

Dependency tracking is based on what gets synchronously accessed during the function’s execution, not on what the function body references. By the time execution resumes after await, the tracking window that Vue uses to record dependencies has already closed. If filters.value is supposed to trigger a re-run when it changes, it silently won’t — and the code reads as if it should, because filters.value is right there in the function. This is the exact bug class behind the search-race scenario at the top of this post: an effect that looks like it reacts to everything it touches, but actually only reacts to what it touched before the first await.

// ✅ Read everything needed before the await — now it's actually tracked
watchEffect(async () => {
  const id = userId.value
  const currentFilters = filters.value  // read synchronously, now tracked
  const data = await fetchUser(id, currentFilters)
})

watchEffect vs watch — Choosing Between Them

Need the previous value to compare against the new one?
  → watch

Want to react only to specific, named sources — even if the callback
happens to touch other reactive state you don't want tracked?
  → watch

Want every reactive value the function reads to be tracked automatically,
don't need the old value, want it to run immediately on setup?
  → watchEffect

Effect has async code with reads after the first await?
  → watch with explicit sources — don't rely on synchronous tracking at all

Default to watch. watchEffect‘s implicit tracking is convenient right up until an await or a conditional read makes “everything the function touches” different from “everything Vue actually tracked” — and that gap is invisible in the code and only shows up as a bug report you can’t reproduce on demand.


The Async Watcher Race Condition — Measured

Back to the opening scenario. Here’s the exact pattern that causes it:

// Looks correct. Isn't, under real network timing.
watch(searchQuery, async (query) => {
  const results = await searchApi(query)
  searchResults.value = results
})

Type “vue”, then before that request resolves, type “vue watch”. Two requests are now in flight. Network response order is not guaranteed to match request order — a longer or more congested response for “vue” can resolve after the response for “vue watch”. Whichever callback resolves last wins the write to searchResults.value, regardless of which query the user is currently looking at.

A rough simulation of the race, with artificial latency to make it deterministic for the example:

async function fakeSearch(query, delayMs) {
  await new Promise(r => setTimeout(r, delayMs))
  return `results for "${query}"`
}

watch(searchQuery, async (query) => {
  const delay = query === 'vue' ? 300 : 50 // "vue" is slower to resolve
  const results = await fakeSearch(query, delay)
  searchResults.value = results
})

searchQuery.value = 'vue'         // fires at t=0ms, resolves at t=300ms
searchQuery.value = 'vue watch'   // fires at t=50ms, resolves at t=100ms

// t=100ms: searchResults.value = 'results for "vue watch"'  (correct, so far)
// t=300ms: searchResults.value = 'results for "vue"'        (stale write wins)
// Final state: user typed "vue watch", sees results for "vue"

Three fixes, in order of preference.

Fix 1 — AbortController, when the API layer supports cancellation. This is the best fix because it cancels the actual network request instead of just discarding the response:

let controller = null

watch(searchQuery, async (query) => {
  controller?.abort()
  controller = new AbortController()
  try {
    const results = await searchApi(query, { signal: controller.signal })
    searchResults.value = results
  } catch (err) {
    if (err.name !== 'AbortError') throw err
  }
})

Fix 2 — request-id guard, when cancellation isn’t available:

let currentRequestId = 0

watch(searchQuery, async (query) => {
  const requestId = ++currentRequestId
  const results = await searchApi(query)
  if (requestId === currentRequestId) {
    searchResults.value = results // only the latest request may write
  }
})

Fix 3 — onWatcherCleanup (Vue 3.5+), which ties the abort logic directly to the watcher’s own lifecycle instead of a module-scoped variable:

import { watch, onWatcherCleanup } from 'vue'

watch(searchQuery, (query) => {
  const controller = new AbortController()
  searchApi(query, { signal: controller.signal }).then(results => {
    searchResults.value = results
  })
  onWatcherCleanup(() => controller.abort())
})

The rule: any await inside a watcher needs an explicit answer to “what happens if this resolves after it’s no longer the latest one?” No answer means a latent bug, not a working feature — it just hasn’t failed in front of you yet.


Stopping Watchers Manually

Watchers created inside a component’s setup() (including <script setup>) are automatically torn down when the component unmounts. Watchers created anywhere else — a Pinia store, a composable that conditionally creates one, an event handler — are not, and keep running until something explicitly stops them.

const stop = watch(someRef, (val) => {
  console.log(val)
})

// later
stop()

This is the pattern that leaks silently:

// ❌ No stop handle — every toggle stacks another live watcher
function useConditionalSync(source, enabled) {
  watch(enabled, (isEnabled) => {
    if (isEnabled) {
      watch(source, syncToServer) // a new watcher, every single time
    }
  })
}
// ✅ Captured and stopped explicitly
function useConditionalSync(source, enabled) {
  let stopWatcher = null

  watch(enabled, (isEnabled) => {
    if (isEnabled && !stopWatcher) {
      stopWatcher = watch(source, syncToServer)
    } else if (!isEnabled && stopWatcher) {
      stopWatcher()
      stopWatcher = null
    }
  }, { immediate: true })
}

Toggle enabled on and off 50 times in the broken version and there are 50 live watchers, all still calling syncToServer on every change to source. Nobody notices in dev — nobody toggles a setting 50 times while testing a feature. It shows up six weeks later as unexplained API call volume in production telemetry, with no exception, no crash, and no obvious line to blame.

Rule: any watcher created outside a component’s own setup() needs its stop handle captured and called explicitly once its job is done.


Choosing the Right Tool

Deriving a value from other reactive state, nothing more?
  → computed

Side effect where you need to compare the old value against the new one?
  → watch

Side effect tied to specific, named sources — nothing else should trigger it?
  → watch

Side effect that naturally reads several reactive values, old value not needed?
  → watchEffect

Async work inside the effect?
  → watch + AbortController or a request-id guard — never a bare await

Watcher created outside setup() — store, composable, event handler?
  → capture the stop() handle, call it explicitly

Reacting to a user action you already have a handler for?
  → put the logic directly in the handler, not a watcher on the resulting state

Syncing a prop to local state for v-model-style two-way binding?
  → defineModel() (3.4+), or a computed getter/setter — not two watchers

Practical Patterns

Debounced Search, Done Correctly

Combining the race-condition fix with debouncing — the two problems are independent and both need handling:

import { ref, watch } from 'vue'

const searchQuery = ref('')
const searchResults = ref([])
let debounceTimer = null
let currentRequestId = 0

watch(searchQuery, (query) => {
  clearTimeout(debounceTimer)
  debounceTimer = setTimeout(async () => {
    const requestId = ++currentRequestId
    const results = await searchApi(query)
    if (requestId === currentRequestId) {
      searchResults.value = results
    }
  }, 300)
})

Debouncing reduces how often a request fires. It does not fix the race condition — two debounced requests can still resolve out of order if the network is slow enough. Both guards are needed independently.

Prop-to-Local Sync Without a Double Watcher

// ❌ Two watchers doing what defineModel does in one line
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const localValue = ref(props.modelValue)

watch(() => props.modelValue, (val) => { localValue.value = val })
watch(localValue, (val) => { emit('update:modelValue', val) })
// ✅ defineModel — no watchers, no infinite-loop risk from mismatched equality checks
const modelValue = defineModel()

Watching a Route Param Without Leaking Old Requests

import { watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
let controller = null

watch(
  () => route.params.id,
  async (id) => {
    controller?.abort()
    controller = new AbortController()
    const data = await fetchResource(id, { signal: controller.signal })
    resource.value = data
  },
  { immediate: true }
)

immediate: true is correct here because the same fetch logic genuinely needs to run on initial mount and on every param change — this is the case the option exists for.


The One Rule

If a watcher’s callback only assigns a computed result to a ref, it isn’t a watcher — it’s a computed written the slow way. If a watcher’s callback contains an await, it isn’t safe until you can answer what happens when that await resolves after it stops being relevant. And if a watcher lives outside a component’s setup(), it isn’t cleaned up until you stop it yourself.

Most watcher bugs in production trace back to one of those three sentences being false when the code was written, and nobody finding out until traffic and timing exposed it. Reading the callback body and asking “is this deriving a value, or actually causing a side effect Vue can’t see” before reaching for watch catches most of it before it ships.

Leave a Reply

Your email address will not be published. Required fields are marked *