In Vue 3.5, what does it mean that `computed()` is a lazy effect, and how does it avoid needless recomputation and re-renders?
answer
- both a subscriber and a dep
- dirty flag, no eager run
- recompute on next .value read
- version bumps only on change
basics
~20 sA computed never runs its getter when a source changes; it only marks itself dirty and notifies its readers. It recomputes on the next .value read, and bumps its own version only if the result changed, so readers whose inputs are unchanged skip re-rendering.
solid answer
~40 sA computed is two things at once: a **subscriber** that tracks the refs and reactive keys its getter reads, and a **dep** that effects reading `.value` subscribe to. When a source triggers, the computed's `notify()` just sets a dirty flag and passes the notification to its own subscribers - the getter does not run. On the next `.value` read, Vue checks a global change counter (nothing changed anywhere means return the cache), then compares each source's version with the one it last saw, and only reruns the getter if one moved. After rerunning it bumps its own version only when `Object.is` says the result changed. A component's queued update performs the same version check before rendering, so since 3.4 a computed that re-evaluates to the same value does not re-render the component.
code
ts · 14 linesimport { ref, computed } from 'vue'
const n = ref(2)
let evaluations = 0
const isEven = computed(() => {
evaluations++
return n.value % 2 === 0
})
isEven.value // evaluations === 1
n.value = 4 // getter does not run on a write
n.value = 6 // still not run
isEven.value // evaluations === 2, result still true
isEven.value // evaluations === 2, nothing changed since last readgo deeper
Recall that a computed caches its result and recomputes only when something it read has changed and its value is read again.
Explain the dirty flag, the version checks on read, and why an unchanged result stops the reader's re-render.
Spot computeds that return fresh objects or hide side effects, and use the laziness model to explain why they re-render or misfire.
Decide where derived state belongs - computed, store getter or server - weighing laziness and caching against memory and debuggability.
## Two roles in one object In `packages/reactivity/src/computed.ts`, a computed is a `ComputedRefImpl` that plays two roles: - **Subscriber** - while its getter runs, it is the active subscriber, so every ref or reactive key the getter reads links to the computed, not to whoever asked for the value. - **Dep** - it owns a `dep`, and reading `.value` calls `dep.track()`, so a render effect, a watcher or another computed can depend on it. That layering is why a component's template that reads only `fullName` never subscribes to `firstName` directly: the component subscribes to the computed, and the computed subscribes to its sources. ## What "lazy" means An ordinary effect such as `watchEffect` runs its function when notified (through the scheduler). A computed does not: 1. A source is written; its dep's `trigger()` notifies subscribers. 2. The computed's `notify()` sets its `DIRTY` flag and forwards the notification to its own subscribers. **Its getter does not run.** 3. Nothing more happens until someone reads `.value`. 4. On read, `refreshComputed()` decides whether to recompute. If nothing reads the computed after the change, the getter runs zero times. ## How a read decides whether to recompute `refreshComputed()` checks, in order: 1. **Clean and subscribed?** If the computed is being tracked by someone and is not dirty, return the cache. 2. **Global version fast path.** Vue bumps `globalVersion` on every reactive change. If it equals the value the computed stored last time, nothing anywhere changed: return the cache. 3. **Per-dep versions.** For each linked source, compare the dep's `version` with the version on the link. If none moved (and no source computed changed after refreshing it), return the cache. 4. **Recompute.** Run the getter with fresh tracking, then compare old and new results with `Object.is`. Only if they differ does the computed increment its own `dep.version`. ## Why that stops re-renders A component's update job is `effect.runIfDirty`: before re-rendering it walks its deps and asks whether any version moved, refreshing computed deps on the way. Take a computed `isEven` of a counter `n` that goes from 2 to 4: | Step | What happens | |---|---| | `n` written | `n`'s dep version bumps; `isEven` marked dirty; render job queued | | job runs | checks `isEven`; `n` moved, so the getter reruns and returns `true` again | | compare | `Object.is(true, true)`: `isEven`'s version stays put | | result | no dep of the render moved; render is skipped | The docs state this as a 3.4+ guarantee: a computed only triggers effects when its value changed. ## Unobserved computeds let go of their sources When the last subscriber of a computed goes away, the computed "soft"-unsubscribes from its sources, so writes no longer notify it and it can be garbage-collected with its value. If something reads it again, it pulls: it compares versions and recomputes only if needed. A computed that was never read by an effect behaves the same way: it caches by version comparison rather than by push notifications. ## Consequences worth saying in an interview - A computed getter should be **pure and cheap to re-check**; heavy work is fine because it runs only on read after a real change. - A computed that returns a **new object or array every time** defeats the `Object.is` check, so its readers always update when a source moves. - Side effects inside a getter run at unpredictable times - possibly never - because evaluation is on demand. - Chains of computeds stay cheap: when `total` depends on `subtotal`, which depends on `items`, a read of `total` refreshes `subtotal` first, and if `subtotal` came out unchanged, `total` does not rerun its getter at all. ## How this differs from a watcher | | `computed()` | `watchEffect()` | |---|---|---| | Runs its function when | its `.value` is read after a change | a dependency changes (through the scheduler) | | Produces | a cached, read-only value (unless given a setter) | side effects | | If nobody reads the result | never recomputes | still runs | That laziness is the practical reason to express derived data as a computed rather than as a watcher that writes a second ref: the computed does no work until a reader needs the value, and it stops the propagation when the result did not change.
- In Vue 3.5, what is the global version fast path in a computed?`globalVersion` increments on every reactive change anywhere. A computed remembers the value it saw at its last refresh; if the counter has not moved, no reactive data changed at all, so it returns the cached value without walking its dependency list.
- Why does a computed that builds a fresh array on each run still re-render its readers when a source changes?After recomputing, the computed compares old and new results with `Object.is`. Two distinct arrays are never identical, so its version bumps and every reader's dependency check sees a change, even if the contents are equal.
- Does a computed nobody currently reads keep receiving notifications from its sources?No. When its last subscriber goes away it unsubscribes from its sources, so writes skip it and it can be collected. A later read compares source versions and recomputes only if one moved.
saying these in an interview costs you the question
- A computed recomputes immediately every time one of its sources changes.
- A computed needs a dependency array to know what to watch.
- If a computed's source changes, every component reading it always re-renders.
- Computed caching is time-based and expires after each tick.
- A computed getter is a good place to fire side effects like logging or fetching.