[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-vue-watcheffect-not-tracking-dependencies":3},{"content":4,"lastModified":5},"\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>It was wired up. It sat inside a \u003Ccode>watchEffect\u003C\u002Fcode> that also fetched the list, and the fetch awaited before the amount was ever read.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">const rows = ref&lt;Invoice[]&gt;([])\n\nwatchEffect(async () =&gt; {\n  const page = await fetchInvoices(range.value, status.value)\n  rows.value = page.items.filter(i =&gt; i.total &gt;= minTotal.value)\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F4974915\u002Fpexels-photo-4974915.jpeg?auto=compress&amp;cs=tinysrgb&amp;w=1200\" alt=\"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\" loading=\"lazy\" \u002F>\n\n\u003Ch2>watchEffect only tracks what it reads before the first await\u003C\u002Fh2>\n\n\u003Cp>The API reference puts it in a single tip: \u003Ccode>watchEffect\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>Here \u003Ccode>range\u003C\u002Fcode> and \u003Ccode>status\u003C\u002Fcode> are read on the first line, so both register. \u003Ccode>minTotal\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>Two fixes, and I have used both. The first hoists every read above the await.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">watchEffect(async () =&gt; {\n  const filters = {\n    range: range.value,\n    status: status.value,\n    min: minTotal.value,\n  }\n  const page = await fetchInvoices(filters)\n  rows.value = page.items\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The second drops \u003Ccode>watchEffect\u003C\u002Fcode> for anything that touches the network and passes a source list to \u003Ccode>watch\u003C\u002Fcode>, which is what the dashboard uses now.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">watch(\n  [range, status, minTotal],\n  async ([r, s, min]) =&gt; {\n    const page = await fetchInvoices(r, s)\n    rows.value = page.items.filter(i =&gt; i.total &gt;= min)\n  },\n  { immediate: true },\n)\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Both work. I kept the second because the dependency list is now visible to whoever opens the file next. With \u003Ccode>watchEffect\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>The cleanup hook follows the same rule, quietly\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>\u003Ccode>onWatcherCleanup\u003C\u002Fcode> 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 \u003Ccode>await\u003C\u002Fcode> statement. Put it below the fetch and it registers nothing, with no error to tell you.\u003C\u002Fp>\n\n\u003Cp>The third argument of the callback has no such problem. An \u003Ccode>onCleanup\u003C\u002Fcode> 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 \u003Ca href=\"\u002Fposts\u002Fcanceling-fetch-abortcontroller\u002F\">my notes on AbortController\u003C\u002Fa>.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">watch(range, async (next, _previous, onCleanup) =&gt; {\n  const controller = new AbortController()\n  onCleanup(() =&gt; controller.abort())\n\n  const page = await fetchInvoices(next, { signal: controller.signal })\n  rows.value = page.items\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch2>A watcher runs before the component finishes rendering\u003C\u002Fh2>\n\n\u003Cp>The third surprise was timing. The default flush is \u003Ccode>'pre'\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cp>I had a keep-the-selected-row-visible effect that read \u003Ccode>offsetHeight\u003C\u002Fcode> from a list element and then scrolled to an index. It was always one row behind, and I spent a while blaming the arithmetic.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">watch(selectedIndex, () =&gt; {\n  \u002F\u002F pre-update: the list is still one row shorter\n  listEl.value?.scrollTo({ top: rowHeight * selectedIndex.value })\n}, { flush: 'post' })\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Ccode>watchPostEffect()\u003C\u002Fcode> is the alias if you would rather not pass options. \u003Ccode>flush: 'sync'\u003C\u002Fcode> is the other extreme, and the guide suggests saving it for simple booleans.\u003C\u002Fp>\n\n\u003Ch2>What the previous value is actually worth\u003C\u002Fh2>\n\n\u003Cp>One more thing I had wrong, and this one is about \u003Ccode>watch\u003C\u002Fcode> rather than \u003Ccode>watchEffect\u003C\u002Fcode>. The reason to reach for \u003Ccode>watch\u003C\u002Fcode> is usually the previous value, and that value is a lie once the source is an object.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">const state = reactive({ items: [] as Item[] })\n\n\u002F\u002F Watching a reactive object is implicitly deep.\nwatch(state, (next, previous) =&gt; {\n  console.log(next === previous) \u002F\u002F true, on every nested mutation\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>A getter with \u003Ccode>deep: true\u003C\u002Fcode> behaves the same way. The callback fires on the mutation, but both arguments point at the same object, because the object was never replaced. \u003Ccode>previous !== next\u003C\u002Fcode> is not a change detector, and logging inside that callback will not tell you which field moved.\u003C\u002Fp>\n\n\u003Cp>Watch the narrowest thing that describes the change instead. A primitive, a length, an id. Those compare properly.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-ts\">watch(() =&gt; state.items.length, (count, previousCount) =&gt; {\n  \u002F\u002F two numbers, and the comparison means something\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch2>When the explicit source list pays for itself\u003C\u002Fh2>\n\n\u003Cp>My rule since then is unglamorous. If the callback sends a request, writes to a store, or navigates, it gets \u003Ccode>watch\u003C\u002Fcode> 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, \u003Ccode>watchEffect\u003C\u002Fcode> earns its keep.\u003C\u002Fp>\n\n\u003Cp>And if a \u003Ccode>watchEffect\u003C\u002Fcode> callback turns out to be async, every read gets hoisted to the top. One line of discipline would have saved me the whole morning.\u003C\u002Fp>\n\n\u003Cp>The handles both APIs return also expose \u003Ccode>pause()\u003C\u002Fcode> and \u003Ccode>resume()\u003C\u002Fcode>, which beat the stop-and-rebuild dance I used to do in bulk-edit mode.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ca href=\"\u002Fposts\u002Fvue-destructuring-lost-reactivity\u002F\">destructured prop one\u003C\u002Fa>, 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 \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> so I stop rediscovering them.\u003C\u002Fp>\n","2026-10-07",1791775771998]