•5 min read

Vue watch vs watchEffect: Why My Watcher Stopped Tracking

An invoice dashboard I look after has three filters: a date range, a status dropdown, and a minimum amount. Two of them worked. The amount input did nothing for weeks and nobody reported it, because it was the newest control on the page and looked unfinished.

It was wired up. It sat inside a watchEffect that also fetched the list, and the fetch awaited before the amount was ever read.

const rows = ref<Invoice[]>([])

watchEffect(async () => {
  const page = await fetchInvoices(range.value, status.value)
  rows.value = page.items.filter(i => i.total >= minTotal.value)
})

Everything I had read about watch vs watchEffect was a table of differences. Lazy against eager, explicit source against automatic tracking, a previous value against nothing at all. All accurate, and none of it would have caught this: the problem is a rule about when a dependency can be collected.

A developer sitting at a desk with a hand on their chin, looking at a monitor with a laptop showing a dark code editor open beside it

watchEffect only tracks what it reads before the first await

The API reference puts it in a single tip: watchEffect tracks dependencies during its synchronous execution, so with an async callback only the properties accessed before the first await tick are tracked. Stretch that function over an await and the rest of the body runs as an ordinary promise callback with nobody watching.

Here range and status are read on the first line, so both register. minTotal is read after the await and is never seen. Changing the date range refetched the list. Changing the amount threshold did nothing, which is the kind of bug that looks like somebody else's.

Two fixes, and I have used both. The first hoists every read above the await.

watchEffect(async () => {
  const filters = {
    range: range.value,
    status: status.value,
    min: minTotal.value,
  }
  const page = await fetchInvoices(filters)
  rows.value = page.items
})

The second drops watchEffect for anything that touches the network and passes a source list to watch, which is what the dashboard uses now.

watch(
  [range, status, minTotal],
  async ([r, s, min]) => {
    const page = await fetchInvoices(r, s)
    rows.value = page.items.filter(i => i.total >= min)
  },
  { immediate: true },
)

Both work. I kept the second because the dependency list is now visible to whoever opens the file next. With watchEffect the list is whatever the function happened to read on its last run, and a line added inside an async block three screens down can silently change it. That is fine for a log line and a liability for a request that hits an API.

The cleanup hook follows the same rule, quietly

While I was in there I found the second bug behind the first. Nothing cancelled the old request, so a slow response for the previous filter could land late and paint stale rows over fresh ones.

onWatcherCleanup is the API for that, new in Vue 3.5, and it carries the same constraint: it must be called during the synchronous execution of the callback, and the reference says you cannot call it after an await statement. Put it below the fetch and it registers nothing, with no error to tell you.

The third argument of the callback has no such problem. An onCleanup passed as a function argument is bound to the watcher instance, so the synchronous constraint does not apply to it. One extra parameter in the signature is the difference between aborting the stale request and keeping it. The plain fetch version of the same wiring is in my notes on AbortController.

watch(range, async (next, _previous, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())

  const page = await fetchInvoices(next, { signal: controller.signal })
  rows.value = page.items
})

A watcher runs before the component finishes rendering

The third surprise was timing. The default flush is 'pre', which the guide describes precisely: the callback runs after the parent component updates, but before the owner component's own DOM updates. An effect that measures or scrolls something the component renders is reading the pre-update DOM.

I had a keep-the-selected-row-visible effect that read offsetHeight from a list element and then scrolled to an index. It was always one row behind, and I spent a while blaming the arithmetic.

watch(selectedIndex, () => {
  // pre-update: the list is still one row shorter
  listEl.value?.scrollTo({ top: rowHeight * selectedIndex.value })
}, { flush: 'post' })

watchPostEffect() is the alias if you would rather not pass options. flush: 'sync' is the other extreme, and the guide suggests saving it for simple booleans.

What the previous value is actually worth

One more thing I had wrong, and this one is about watch rather than watchEffect. The reason to reach for watch is usually the previous value, and that value is a lie once the source is an object.

const state = reactive({ items: [] as Item[] })

// Watching a reactive object is implicitly deep.
watch(state, (next, previous) => {
  console.log(next === previous) // true, on every nested mutation
})

A getter with deep: true behaves the same way. The callback fires on the mutation, but both arguments point at the same object, because the object was never replaced. previous !== next is not a change detector, and logging inside that callback will not tell you which field moved.

Watch the narrowest thing that describes the change instead. A primitive, a length, an id. Those compare properly.

watch(() => state.items.length, (count, previousCount) => {
  // two numbers, and the comparison means something
})

When the explicit source list pays for itself

My rule since then is unglamorous. If the callback sends a request, writes to a store, or navigates, it gets watch with a named source list, because I want the trigger conditions readable from the top of the block. If the effect only syncs something to the DOM, watchEffect earns its keep.

And if a watchEffect callback turns out to be async, every read gets hoisted to the top. One line of discipline would have saved me the whole morning.

The handles both APIs return also expose pause() and resume(), which beat the stop-and-rebuild dance I used to do in bulk-edit mode.

None of this is in the comparison table, and the comparison table is not wrong. It is the easy half. The version of this lesson I keep next to it is the destructured prop one, where a value that looked reactive turned out to be a snapshot. Same failure in different clothes: Vue tracks property access, and anything that hides an access from the tracker gets missed. The snippets live in Snippet Ark so I stop rediscovering them.