Vue watch() on an Array: Why push() Doesn't Trigger the Callback
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.
// before
draft.value = [...draft.value, row]
// after: same result, fewer characters, or so I thought
draft.value.push(row)
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.
Vue was not broken. The line I deleted was the only event the watcher had been told to care about.
A watcher on a ref listens for the assignment, not for the contents
Pass a ref to watch and the getter Vue builds is basically () => source.value. The callback runs only when hasChanged(newValue, oldValue) says the value moved, and that check is !Object.is. For a ref holding an array, .value returns the same reactive proxy on every read, so push() writes through that proxy, the identity never changes, and the comparison comes back equal.
I stopped guessing and ran these against the copy of Vue in node_modules:
const list = ref([{ id: 1 }])
watch(list, cb) // push -> 0 calls
watch(list, cb, { deep: true }) // push -> 1 call
watch(list, cb, { deep: 1 }) // push -> 1 call
watch(() => list.value, cb) // push -> 0 calls
watch(list.value, cb) // push -> 1 call
watch(() => list.value.length, cb) // push -> 1 call
const items = reactive([1, 2])
watch(items, cb) // push -> 1 call
The last line is the one that makes all of this feel arbitrary until you read the source. watch has a separate getter for reactive sources, and it does not return the object as-is:
const reactiveGetter = (source) => {
if (deep) return source
if (isShallow(source) || deep === false || deep === 0)
return traverse(source, 1)
return traverse(source)
}
So watch(reactiveArray, cb) walks the array on every run and subscribes to every index in it. The forceTrigger 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 push version did not.
Same reason watch(() => props.rows, cb) does nothing when the parent pushes into an array it passes down. The getter hands back the same proxy it returned last time.
deep: true buys the truth, and you pay for it on every write
Adding deep: true fixes the bug. It also wraps the source getter in traverse(), 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 deep: true, about 9ms with deep: 1. 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.
Numeric depth, which most posts about this do not mention
Since Vue 3.5, deep also takes a number for the maximum traversal depth. The conversion is a single line in watch itself, const depth = deep === true ? Infinity : deep, and traverse decrements it on each level down.
deep: 1 on a ref holding an array reads length and every index, one level, and stops. That catches push, splice 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 deep: true for a number.
const rows = ref([{ id: 1, name: 'x' }])
watch(rows, save, { deep: 1 })
rows.value.push({ id: 2, name: 'y' }) // fires
rows.value[0] = { id: 9, name: 'z' } // fires
rows.value[0].name = 'changed' // silent
A table that replaces whole rows from an API response and never edits fields in place fits deep: 1 exactly. One edge case: deep: 0 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.
What I actually ship now
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.
const fingerprint = computed(() =>
draft.value.map(r => `${r.id}:${r.tags.join('|')}`).join(','),
)
watch(fingerprint, persist)
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.
An earlier version of that screen only ever appended rows, and there a length was enough.
watch(() => draft.value.length, persist)
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.
The failure in the other direction
The same file had the mirror bug. A filtered computed was being watched, and the callback fired when nothing visible had changed, because filter() returns a brand new array on every recompute and a new array is never equal to the old one.
const active = computed(() => rows.value.filter(r => r.enabled))
watch(active, ...) // fires even when the filtered result is identical
Push a disabled row into rows and the watcher runs, even though active holds the same records as before. The fingerprint fixes this one too, or you compare inside the callback and return early.
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 watchEffect that stopped tracking after an await, the destructured prop that turned into a snapshot, and the watcher that never stopped at all.
Vue 3.6 is in RC with a rewritten reactivity package, and the public watch API is unchanged, so do not wait for an upgrade to fix this. The broken line is still your line.
The working watchers from this file live in Snippet Ark, so the next review comment about noisy spreads can get a link instead of an outage.