[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-vue-watch-array-push-not-triggering":3},{"content":4,"lastModified":5},"\u003Cp>The bulk editor in our admin panel autosaves drafts to localStorage. It had been dependable for months. Then a review comment said my array spread was noisy, so I changed one line.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">\u002F\u002F before\ndraft.value = [...draft.value, row]\n\n\u002F\u002F after: same result, fewer characters, or so I thought\ndraft.value.push(row)\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The table still rendered the new row. The counter at the bottom of the drawer still updated. Only the autosave watcher went silent, and it stayed silent for four days, until someone lost an afternoon of tagging when their tab crashed.\u003C\u002Fp>\n\n\u003Cp>Vue was not broken. The line I deleted was the only event the watcher had been told to care about.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F1393972\u002Fpexels-photo-1393972.jpeg?auto=compress&amp;cs=tinysrgb&amp;w=1200\" alt=\"A row of ivory domino tiles standing on a reflective table, each one leaning against the next\" loading=\"lazy\" \u002F>\n\n\u003Ch2>A watcher on a ref listens for the assignment, not for the contents\u003C\u002Fh2>\n\n\u003Cp>Pass a ref to \u003Ccode>watch\u003C\u002Fcode> and the getter Vue builds is basically \u003Ccode>() =&gt; source.value\u003C\u002Fcode>. The callback runs only when \u003Ccode>hasChanged(newValue, oldValue)\u003C\u002Fcode> says the value moved, and that check is \u003Ccode>!Object.is\u003C\u002Fcode>. For a ref holding an array, \u003Ccode>.value\u003C\u002Fcode> returns the same reactive proxy on every read, so \u003Ccode>push()\u003C\u002Fcode> writes through that proxy, the identity never changes, and the comparison comes back equal.\u003C\u002Fp>\n\n\u003Cp>I stopped guessing and ran these against the copy of Vue in \u003Ccode>node_modules\u003C\u002Fcode>:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const list = ref([{ id: 1 }])\n\nwatch(list, cb)                       \u002F\u002F push -> 0 calls\nwatch(list, cb, { deep: true })       \u002F\u002F push -> 1 call\nwatch(list, cb, { deep: 1 })          \u002F\u002F push -> 1 call\nwatch(() =&gt; list.value, cb)           \u002F\u002F push -> 0 calls\nwatch(list.value, cb)                 \u002F\u002F push -> 1 call\nwatch(() =&gt; list.value.length, cb)    \u002F\u002F push -> 1 call\n\nconst items = reactive([1, 2])\nwatch(items, cb)                      \u002F\u002F push -> 1 call\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The last line is the one that makes all of this feel arbitrary until you read the source. \u003Ccode>watch\u003C\u002Fcode> has a separate getter for reactive sources, and it does not return the object as-is:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const reactiveGetter = (source) =&gt; {\n  if (deep) return source\n  if (isShallow(source) || deep === false || deep === 0)\n    return traverse(source, 1)\n  return traverse(source)\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>So \u003Ccode>watch(reactiveArray, cb)\u003C\u002Fcode> walks the array on every run and subscribes to every index in it. The \u003Ccode>forceTrigger\u003C\u002Fcode> flag set alongside it makes the callback fire even when old and new are the same object. A plain ref gets none of that walk, which is why the spread version worked and the \u003Ccode>push\u003C\u002Fcode> version did not.\u003C\u002Fp>\n\n\u003Cp>Same reason \u003Ccode>watch(() =&gt; props.rows, cb)\u003C\u002Fcode> does nothing when the parent pushes into an array it passes down. The getter hands back the same proxy it returned last time.\u003C\u002Fp>\n\n\u003Ch2>deep: true buys the truth, and you pay for it on every write\u003C\u002Fh2>\n\n\u003Cp>Adding \u003Ccode>deep: true\u003C\u002Fcode> fixes the bug. It also wraps the source getter in \u003Ccode>traverse()\u003C\u002Fcode>, which reads every property in the structure so each one becomes a tracked dependency. Not free. I put 20,000 small objects in a ref, pushed one more, and timed from the push to the callback firing: about 110ms with \u003Ccode>deep: true\u003C\u002Fcode>, about 9ms with \u003Ccode>deep: 1\u003C\u002Fcode>. One run on my laptop with Vue 3.5.41, so read it as an order of magnitude. Full traversal of a big array on every keystroke is how a smooth form starts to stutter.\u003C\u002Fp>\n\n\u003Ch2>Numeric depth, which most posts about this do not mention\u003C\u002Fh2>\n\n\u003Cp>Since Vue 3.5, \u003Ccode>deep\u003C\u002Fcode> also takes a number for the maximum traversal depth. The conversion is a single line in \u003Ccode>watch\u003C\u002Fcode> itself, \u003Ccode>const depth = deep === true ? Infinity : deep\u003C\u002Fcode>, and \u003Ccode>traverse\u003C\u002Fcode> decrements it on each level down.\u003C\u002Fp>\n\n\u003Cp>\u003Ccode>deep: 1\u003C\u002Fcode> on a ref holding an array reads \u003Ccode>length\u003C\u002Fcode> and every index, one level, and stops. That catches \u003Ccode>push\u003C\u002Fcode>, \u003Ccode>splice\u003C\u002Fcode> and replacing an element. It does not catch a field changing inside an element. I tested both directions, because the second is what burns people who swap \u003Ccode>deep: true\u003C\u002Fcode> for a number.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const rows = ref([{ id: 1, name: 'x' }])\n\nwatch(rows, save, { deep: 1 })\n\nrows.value.push({ id: 2, name: 'y' })   \u002F\u002F fires\nrows.value[0] = { id: 9, name: 'z' }    \u002F\u002F fires\nrows.value[0].name = 'changed'          \u002F\u002F silent\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>A table that replaces whole rows from an API response and never edits fields in place fits \u003Ccode>deep: 1\u003C\u002Fcode> exactly. One edge case: \u003Ccode>deep: 0\u003C\u002Fcode> is not \"one level\" on a ref. Zero is falsy, so the traversal wrapper is never applied and you are back to the original bug.\u003C\u002Fp>\n\n\u003Ch2>What I actually ship now\u003C\u002Fh2>\n\n\u003Cp>Neither depth flag made it into the panel. I watch a fingerprint of the data instead, built in a computed so it only rebuilds when a field it actually read changes.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const fingerprint = computed(() =&gt;\n  draft.value.map(r =&gt; `${r.id}:${r.tags.join('|')}`).join(','),\n)\n\nwatch(fingerprint, persist)\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>One dependency, no double fire for a single edit, and the previous value is a string I can print when the save does not happen. Adding a row changes it, removing one changes it, retagging a row changes it, and nothing else does.\u003C\u002Fp>\n\n\u003Cp>An earlier version of that screen only ever appended rows, and there a length was enough.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">watch(() =&gt; draft.value.length, persist)\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The price is a string built on every invalidation. Fine for a few hundred rows, wrong on the 20k-row import screen, where that string runs into hundreds of kilobytes.\u003C\u002Fp>\n\n\u003Ch2>The failure in the other direction\u003C\u002Fh2>\n\n\u003Cp>The same file had the mirror bug. A filtered computed was being watched, and the callback fired when nothing visible had changed, because \u003Ccode>filter()\u003C\u002Fcode> returns a brand new array on every recompute and a new array is never equal to the old one.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const active = computed(() =&gt; rows.value.filter(r =&gt; r.enabled))\nwatch(active, ...)   \u002F\u002F fires even when the filtered result is identical\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Push a disabled row into \u003Ccode>rows\u003C\u002Fcode> and the watcher runs, even though \u003Ccode>active\u003C\u002Fcode> holds the same records as before. The fingerprint fixes this one too, or you compare inside the callback and return early.\u003C\u002Fp>\n\n\u003Cp>The model I keep now is short. A watcher fires when a tracked read gets invalidated and the value it compares looks different. If that comparison is object identity, mutation is invisible, and no amount of staring at the template will tell you why. Same family as the \u003Ca href=\"\u002Fposts\u002Fvue-watcheffect-not-tracking-dependencies\u002F\">watchEffect that stopped tracking after an await\u003C\u002Fa>, the \u003Ca href=\"\u002Fposts\u002Fvue-destructuring-lost-reactivity\u002F\">destructured prop that turned into a snapshot\u003C\u002Fa>, and the \u003Ca href=\"\u002Fposts\u002Fvue-watch-cleanup-watchers-never-stop\u002F\">watcher that never stopped at all\u003C\u002Fa>.\u003C\u002Fp>\n\n\u003Cp>Vue 3.6 is in RC with a rewritten reactivity package, and the public \u003Ccode>watch\u003C\u002Fcode> API is unchanged, so do not wait for an upgrade to fix this. The broken line is still your line.\u003C\u002Fp>\n\n\u003Cp>The working watchers from this file live in \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa>, so the next review comment about noisy spreads can get a link instead of an outage.\u003C\u002Fp>\n","2026-11-11",1794383305982]