In Vue 3, why can a watch callback receive a newValue identical to oldValue, and how do you get a genuine previous value?
answer
- references, not snapshots
- in-place mutation keeps identity
- deep mode and reactive sources
- watch a primitive or a copy
basics
~20 sDeep and reactive-object watchers fire on in-place mutation while the source keeps returning the same object, so Vue passes that one object as both values. For a real previous value, watch a primitive or return a fresh copy from a getter.
solid answer
~40 sThe callback receives what the source produced on this run and on the previous one, stored **by reference**. For a reactive object source, or a getter with `deep: true`, a nested mutation such as `state.items.push(x)` triggers the watcher, but the source still returns the **same proxy**, so `newValue === oldValue` and both already show the change. Vue does not clone anything to keep history. To compare, change the source: a getter returning a primitive (`() => state.items.length`), an array of getters for specific fields, or a getter that builds a fresh copy such as `() => ({ ...state.filters })`, whose old and new objects are then distinct. When the object itself is replaced, the two values differ as usual.
code
ts · 17 linesimport { reactive, watch } from 'vue'
const state = reactive({ cart: { qty: 1 } })
// Same object twice: both show qty 2 after the mutation
watch(() => state.cart, (n, o) => console.log(n === o, o.qty), { deep: true })
// Distinct values: a primitive getter
watch(() => state.cart.qty, (qty, prevQty) => console.log(prevQty, '->', qty))
// Distinct objects: a fresh shallow copy on every run
watch(() => ({ ...state.cart }), (n, o) => console.log(o.qty, '->', n.qty))
state.cart.qty++
// true 2
// 1 -> 2
// 1 -> 2go deeper
Know that a watcher on a reactive object or with deep: true can get the same object as new and old value after a mutation.
Explain why: Vue stores results by reference and in-place mutation keeps identity; show the primitive getter and fresh-copy getter fixes.
Recognise the production bugs this causes, such as undo stacks or change detection built on oldValue, and choose sources that make comparisons real.
Weigh snapshot strategies across a codebase: explicit field getters, serialization or immutable updates, trading clarity against per-run cost.
## What the two arguments actually are A Vue 3 `watch()` callback is called as `callback(newValue, oldValue, onCleanup)`. Both values come from the watcher's **source**: `newValue` is what the source produced on this run, and `oldValue` is what it produced on the previous run. Vue keeps that previous result **by reference**. It does not copy, freeze or diff it. For primitives the distinction is invisible: a number or string cannot change in place, so `oldValue` is always the earlier value. For objects it matters a great deal. ## When they are the same object Whenever the callback is triggered by an **in-place mutation** of an object that the source keeps returning, both arguments point at the same object: - `watch(state, cb)` with `state = reactive({...})`: the source *is* the proxy, it is watched deeply, and after `state.items.push(x)` Vue calls the callback with `state` as both values. - `watch(() => state.cart, cb, { deep: true })`: the getter returns the same `cart` proxy on each run, so a change to `state.cart.qty` passes that proxy twice, already showing the new quantity. - `watch(listRef, cb, { deep: true })` on a `ref([])`: `push` mutates the array held in `.value`, so both arguments are the same array. The official API reference states it directly: in deep mode, the new value and the old are the same object if the callback was triggered by a deep mutation. They differ only when the object itself is **replaced**, for example `state.cart = { ...state.cart, qty: 2 }`. | Source | Trigger | `newValue === oldValue`? | |---|---|---| | reactive object | nested mutation | yes | | getter + `deep: true` | nested mutation | yes | | getter + `deep: true` | object replaced | no | | getter returning a primitive | value changed | no, two different primitives | | getter returning a fresh copy | any tracked change | no, a new object each run | ## Why Vue hands you references A real previous object would require deep-copying the watched structure before every mutation, for every watcher, whether or not the callback ever looks at `oldValue`. That is costly on large data and ill-defined for values such as class instances, `Map`s with object keys or objects marked with `markRaw()`. A deep watcher already traverses the structure to **track** it; it does not also **copy** it. The snapshot decision is left to you, and you make it by choosing the source. ## Getting a genuine previous value Choose a source whose result changes identity when the state you care about changes: 1. **Watch the primitive you care about.** `() => state.cart.qty` or `() => state.items.length` give distinct old and new numbers. 2. **Watch several specific fields** with an array of getters; the callback receives arrays of old and new primitives. 3. **Return a fresh copy from the getter**, such as `() => ({ ...state.filters })`. Each run builds a new object, so old and new differ. A spread is shallow: it reads, tracks and copies only top-level fields, so nested changes are neither tracked nor preserved. 4. **Serialize** with `() => JSON.stringify(state.filters)` when a small nested object must be compared by content; it reads every field and compares strings, at the cost of a full serialization per run. What does not work is keeping `deep: true` and comparing `oldValue.qty` with `newValue.qty`: both expressions read the same live object and always agree. ## Red flags in code review - a deep watcher that compares `oldValue` fields to decide whether to act; - an undo feature that pushes `oldValue` from a deep watcher onto a history stack, which fills up with references to one live object; - logging `oldValue` from a reactive-object watcher and trusting the console to show the previous state.
- Does `oldValue` ever differ from `newValue` in a deep watcher?Yes, when the watched object is replaced rather than mutated. With `watch(() => state.cart, cb, { deep: true })`, assigning `state.cart = { qty: 3 }` makes the getter return a new object, so `newValue` is the new cart and `oldValue` the previous one. Only in-place nested mutations produce the same reference twice.
- What is the catch with `() => ({ ...state.filters })` as a source?The spread is shallow. It reads and tracks only top-level fields, so a change to `state.filters.range.min` is not tracked at all, and the copy shares nested objects with the original. It also builds a new object on every run, so any trigger on a tracked top-level field calls the callback, and you compare the copied fields yourself to learn what changed.
Two bookmarks to the same shared online document: after someone edits it, both bookmarks open the edited page. To see the earlier version you must have saved your own copy before the edit.
saying these in an interview costs you the question
- Vue snapshots the old object before each mutation for the callback
- newValue === oldValue means the watcher fired by mistake
- Comparing oldValue.qty with newValue.qty in a deep watcher detects changes
- A reactive object source does not fire on nested mutations
- Adding immediate: true makes Vue keep a real previous copy