In Vue 3.4+, why does a computed that returns a new object each run still trigger its dependents, and how do you stabilize it?
answer
- compares with the cached value
- identity, not deep
- getter's first argument
- compute fully, then compare
basics
~20 sSince Vue 3.4 a computed notifies dependents only when its result changed by identity. A freshly built object is always different, so return the previous value, passed as the getter's first argument, when the relevant fields are equal.
solid answer
~40 sIn Vue 3.4 and later, when a computed re-evaluates, Vue compares the new result with the cached one using `Object.is` semantics and notifies dependents only if they differ. That makes primitive results such as `isEven` naturally stable: going from 2 to 4 re-evaluates it but triggers nothing. An object or array built inside the getter is new every time, so every re-evaluation looks like a change and every dependent - a watcher, a render, a child receiving it as a prop - runs again. The fix is the getter's first argument, the previous value: build the full new result first so all dependencies are read, compare the fields that matter, and return the previous object when nothing changed. Use it where the dependents are expensive; the comparison itself is work.
code
ts · 19 linesimport { computed, shallowRef } from 'vue'
interface Filters { status: string; page: number }
// The form replaces the whole object on every edit.
const filters = shallowRef<Filters>({ status: 'open', page: 1 })
// Unstable: a new object whenever filters is replaced, even if only page changed.
const statusQuery = computed(() => ({ status: filters.value.status }))
// Stable (3.4+): return the previous object when the relevant field is equal.
const stableStatusQuery = computed<{ status: string }>(previous => {
const next = { status: filters.value.status }
if (previous && previous.status === next.status) return previous
return next
})
filters.value = { ...filters.value, page: 2 }
// Both re-evaluate; only statusQuery notifies its dependents.go deeper
Remember that a computed returning the same primitive value does not wake up what depends on it.
Explain the 3.4 identity check, why new objects always count as changed, and the previous-value argument.
Find unstable computed objects feeding heavy children or watchers and stabilize them, keeping the full computation before the comparison.
Encourage primitive or narrow computed values as a design habit rather than retrofitting comparisons everywhere.
## What changed in Vue 3.4 A `computed` is lazy and cached: it re-evaluates only after a dependency changed and something reads it. Since **Vue 3.4**, there is a second filter. After re-evaluating, Vue compares the new result with the cached one and **notifies dependents only if the value actually changed**. The comparison is identity with `Object.is` semantics. The performance guide's example: - `const isEven = computed(() => count.value % 2 === 0)` - a `watchEffect` logs `isEven.value` - setting `count` to 2 and then 4 re-evaluates `isEven`, but the value stays `true`, so the effect does **not** run again. ## Why objects defeat it If the getter builds a new object or array, the new result is never identical to the old one: - `computed(() => ({ isEven: count.value % 2 === 0 }))` returns a different object on every run; - Vue does not deep-compare, because a deep comparison could cost more than it saves; - every re-evaluation therefore notifies all dependents, even when `isEven` is unchanged. The cost lands on whatever depends on the computed: watchers re-run their callbacks, the owning component re-renders, and a child receiving the object as a prop fails the identity check and updates too. ## The fix: compare against the previous value Since 3.4 the computed getter receives the **previous value** as its first argument. The pattern: 1. **Compute the full new result first**, reading every source the result depends on. 2. **Compare** the fields that matter with the previous value. 3. **Return the previous object** when they are equal; otherwise return the new one. Step 1 is not optional. The reads made during this run define the computed's dependencies for the next run. Returning early before reading a source would drop that source from tracking, and later changes to it would never re-run the computed. ## When to bother | Situation | Worth stabilizing? | |---|---| | primitive result (`boolean`, `number`, `string`) | already stable | | object result read by one cheap template binding | usually not | | object result passed as a prop to a heavy child or to many list rows | yes | | object result watched by a watcher that fetches or writes | yes | | large array result that needs deep comparison | measure; comparison may cost as much as it saves | ## Walking through the example In the code example, `filters` is a `shallowRef` that the form replaces on every edit. Suppose the user changes only the page: 1. `filters.value` is replaced, so both computed values are marked for re-evaluation. 2. When read, `statusQuery` builds `{ status: 'open' }` - a new object. The identity check sees a change, so its dependents run: a watcher that refetches, or a child that receives it as a prop. 3. `stableStatusQuery` also builds `{ status: 'open' }`, sees that `status` equals the previous object's, and returns the previous object. The identity check sees no change, and its dependents stay idle. Both computed values did the same amount of work themselves; the difference is entirely in what they caused downstream, which is usually where the real cost is. ## Related habits - **Prefer primitive computed values** where a consumer needs only a flag or a count; they are stable for free. - **Split a computed** that bundles unrelated fields into an object into several smaller computed values, so each consumer depends only on what it reads. - **Do not wrap the result in `reactive()`** hoping to stabilize it; a new object is still a new object. The stability rule complements, and does not replace, caching: caching prevents redundant evaluation, while the 3.4 comparison prevents redundant notifications after an evaluation that produced the same answer.
- Why must the getter compute the full result before returning the previous value?A computed's dependencies are whatever it reads during its latest run. If it returns early without reading a source, that source is no longer tracked, so later changes to it never re-run the computed. Reading everything first keeps the dependency set complete.
- How does computed stability affect a child that receives the result as a prop?The parent's child-update check compares props by identity. A stabilized computed hands the child the same object when nothing relevant changed, so the child is skipped. An unstable one gives it a new object on every re-evaluation and forces an update even when the content is equal.
saying these in an interview costs you the question
- Vue deep-compares computed object results before notifying dependents.
- A computed returning an unchanged boolean still re-triggers its watchers in Vue 3.5.
- Returning the previous value before reading the sources is a safe shortcut.
- Wrapping the returned object in reactive() makes the computed stable.
- A computed's getter receives the changed dependency as its argument.