The Method That Recalculates on Every Keystroke: How the computed Cache Works
A checkout screen shows a cart line-item table and a search box above it. The total is computed with a method:
<script setup>
const items = ref([...])
function totalPrice() {
return items.value.reduce((sum, i) => sum + i.price * i.qty, 0)
}
</script>
<template>
<input v-model="search" />
<p>Total: {{ totalPrice() }}</p>
</template>
Every keystroke in the search box mutates search, the component re-renders, and totalPrice() runs again — even though the cart did not change. A plain method gives Vue no information about what it reads, so the only safe strategy is to call it on every render. With a small cart that is waste you never notice; with a thousand line items, a live FX-rate table, or several such computations on one page, it is the difference between a responsive form and a laggy one.
computed solves this by making the derivation a tracked, cached value:
const totalPrice = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.qty, 0)
)
Calling computed(getter) does not run the getter. It returns a read-only ref-like object immediately, and the getter stays dormant until something reads totalPrice.value — or, more commonly, until the template reads it during render. At that first read, the getter executes, and while it runs, every reactive property it touches registers the computed as a subscriber: items (the ref), the reactive array behind it, each item's price and qty properties. That registration is the whole trick.
From then on, the cache follows a precise lifecycle:
- A tracked dependency mutates (
items.value.push(...)). The reactivity system marks the computed dirty — it does not immediately re-run the getter. - The component whose render effect read the computed is notified and re-renders.
- The re-render reads
totalPrice.valueagain, sees the dirty flag, re-runs the getter exactly once, caches the fresh result, and clears the flag. - Every later read — from this component or any other — returns the cached result until the next mutation of a tracked dependency.
Two consequences matter in practice. First, the getter re-runs only when a tracked dependency mutates — not on every render, not when an unrelated sibling state changes, not on a timer. Typing in the search box leaves the cached total untouched. Second, evaluation is lazy: if nothing reads the computed after an invalidation, the getter never runs at all. A computed nobody renders costs nothing.
| Method call in template | computed |
|
|---|---|---|
| When it runs | Every render of the component | Only after a tracked dependency changes, at the next read |
| Result between changes | Recomputed each time | Cached |
| Vue knows its dependencies | No | Yes — collected during the getter |
| Read-only by contract | No | Yes — assignment warns in dev |
This is the exact question interviewers reach for: "Why is computed cached while a method isn't?" The answer is not that Vue special-cases computed — it is that the computed getter is executed through the reactivity system, so Vue records what it reads and can invalidate it precisely. A method is an opaque function as far as Vue is concerned; the render just calls it.
Writable Computed Values extends this same cache with a setter when a derived value also needs to accept input.
One Derived Value, Many Readers: Staying Consistent Across Components
The cart total appears in three places: the header badge, the checkout sidebar, and a footer summary. The requirement is blunt — all three must show the same number, including in the same frame. Two designs get this wrong in characteristic ways.
The first wrong design copies the value into local state:
// in each component's setup
const total = ref(cartTotal.value) // snapshot — frozen link
Each copy is taken once at setup time and never follows the cart again. This is the detach pattern from Common Reactivity Pitfalls, and it is the most common way derived UI desynchronizes.
The second wrong design recomputes independently in each component with its own method and rounds or formats along the way — one component rounds at cents, another at whole units. Nothing forces the copies to agree.
The working design gives each reader a link to the derivation, not to a value:
// useCartTotals.js — a composable shared by all three components
import { computed } from 'vue'
import { cartItems } from './cartState'
export function useCartTotals() {
return {
total: computed(() =>
cartItems.value.reduce((s, i) => s + i.price * i.qty, 0)
)
}
}
Because every component holds a ref to a derivation over shared reactive state, all reads in the same tick observe the same dependency snapshot: after cartItems.value.push(item), the header, sidebar, and footer each re-render by reading their computed, and each read recomputes at most once against the same source. No component can show 42.00 while another shows 39.00 — there is a single source (the cart state) and a deterministic function over it.
For an even stronger guarantee, define the computed once — at module scope or as a Pinia store getter — and have every component read that one instance. One cache, one dirty flag, one recomputation per change, and identical values everywhere by construction.
Two reading rules complete the picture:
In script, keep the link. A computed is a ref; script code reads totalPrice.value and must never destructure or snapshot it:
const total = totalPrice.value // WRONG — freezes the number
const total = computed(() => totalPrice.value) // also pointless — re-wraps
// right: use totalPrice.value at the moment you need it, or pass the ref along
In templates, the unwrap is automatic. Top-level setup bindings — including computeds — are unwrapped by the compiler, so {{ totalPrice }} renders the number with no .value. Props behave the same way. The unwrapping map from the reactivity topics still governs the edge cases: a computed stored inside a reactive object's property unwraps on access, while a computed inside an array does not.
Computed also composes:
const subtotal = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0))
const shipping = computed(() => (subtotal.value > 100 ? 0 : 9.99))
const grandTotal = computed(() => subtotal.value + shipping.value)
Invalidation propagates along the chain: change an item, subtotal goes dirty, and shipping and grandTotal go dirty with it — each recomputing lazily at most once when first read. Keep chains short and single-purpose; a six-level computed chain is debuggable only with devtools, and the computed-versus-watch decision gets harder the more indirection you add.
Three Ways to Break the computed Contract: Async, Effects, and Snapshots
The computed getter carries an implicit contract: synchronous, pure, read through the ref. Each violated clause fails differently, and each has a canonical wrong version you will meet in real codebases.
1. Async getters. The tempting version of a computed user card:
// BROKEN — the computed caches a Promise
const user = computed(async () => {
const res = await fetch(`/api/users/${userId.value}`)
return res.json()
})
user.value is now a Promise object, and a template that renders it displays nothing useful — you cannot await inside an interpolation. The cache makes it worse: the same promise is returned forever, so even imperative workarounds never refetch. The getter must return a resolved value synchronously. Async work belongs in a ref that an effect updates — watchEffect with onCleanup, or async setup() under <Suspense> — as the watchEffect topic shows. If the data is already in reactive state, the computed is fine; the moment a fetch is involved, it is not a computed's job.
2. Side effects in the getter. Logging, analytics, and especially writing to other reactive state are pure-function violations with concrete failure modes:
// BROKEN — mutates its own dependency
const doubled = computed(() => {
count.value++ // writes to a tracked dependency of this very getter
return count.value * 2
})
The write invalidates the computed that performed it; the next read re-runs the getter, writes again, and the app dies with Maximum recursive updates exceeded. The loop is just as real when the getter mutates some other state that the render also reads. Beyond loops, effects are semantically wrong here because invocation count is undefined: a lazy computed may run zero times (nothing read it, so your analytics event never fires) or several times (re-render, HMR, dev double-invocation). Side effects need a home where invocation means something — a watch callback with defined timing.
3. Reading .value once and keeping the snapshot. The classic form is the Options-API habit leaking into Composition API:
export default {
setup() {
const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0))
return { total: total.value } // BROKEN — exports a frozen number
}
}
Return the computed itself, or use <script setup>, where bindings are exported automatically as references. The same cut appears when passing total.value into a helper that stores it — the helper receives a number, not a link. If a component needs the live value, pass the computed ref and let each read go through .value at the moment of use.
A frequent false alarm, worth separating from real bugs: conditional reads inside the getter are safe.
const label = computed(() => (user.value ? `Hello, ${user.value.name}` : 'Guest'))
Dependencies are re-collected on every getter run, so when user flips from null to an object, the computed re-runs and begins tracking user.name as well. The "you must read everything up front" worry applies to watchEffect's tracking window, not to computed — the watchEffect topic draws that line precisely.
The interview-compact version: computed is a lazy, cached, read-only derivation. Make it async and it caches promises; make it impure and it loops or under-fires; read it once and keep the number and you have rebuilt the stale-copy bug in a new costume.
Quiz
1. A component template renders `{{ fullName }}` where fullName is a computed over firstName and lastName. The user types in an unrelated search box that also drives this component's re-render. When does the fullName getter run?
2. In a development build, template code runs `total.value = 0` where total is a read-only computed. What happens?
3. A developer writes `const profile = computed(async () => (await fetchUser()).json())` and renders `{{ profile }}`. What is the result, and what is the fix?
4. Three components each read a shared module-level computed over the same cart state. One component pushes an item into the cart. What do all three render after the update?
Flashcards
Question
What does computed(getter) return, and when does the getter first execute?
Click to reveal answer
Answer
A read-only ref-like object. The getter does not run at computed() call time — it runs lazily at the first read of .value (usually the template's first render), at which point every reactive property it touches is registered as a dependency.
Question
When does a computed getter re-run after its creation?
Click to reveal answer
Answer
Only when a dependency it tracked during its last run mutates — and then only at the next read (dirty flag). It does not re-run on unrelated re-renders, and if nothing reads it after invalidation, it never re-runs at all.
Question
What enforces the read-only contract of a getter-only computed?
Click to reveal answer
Answer
Assignment to its .value warns in dev ('Write operation failed: computed value is readonly') and is a no-op. To accept writes, pass an object with get and set functions to computed() — the writable computed pattern.
Question
Why is an async computed getter broken, and where does async data belong instead?
Click to reveal answer
Answer
The getter must return a resolved value synchronously; an async function returns a Promise, which the cache stores forever — the template renders nothing useful and the data never refreshes. Put the fetch in a ref updated by watchEffect (with onCleanup) or in async setup under <Suspense>, and let the computed derive from that ref.
Question
Why do multiple components reading one shared computed always agree, while local copies drift?
Click to reveal answer
Answer
A single computed instance is a single cache with a single dirty flag over one source of truth: a mutation invalidates it once and every reader re-reads the same fresh value in the same update. Copying the value (ref(total.value), destructuring, per-component methods with different rounding) severs the link, so each copy reflects whenever it happened to be taken.
Revision Notes
Key Takeaways
- 1. computed(getter) returns a read-only ref and runs the getter lazily — at first read, never at creation
- 2. The getter re-runs only when a tracked dependency mutates, and only at the next read; unrelated re-renders hit the cache
- 3. Templates unwrap computed refs like any top-level setup binding; script must read .value and must never snapshot it
- 4. Getter contract: synchronous, pure, read through the ref — async caches Promises, effects under- or over-fire, self-mutation loops
- 5. Sharing one computed instance across components is what keeps every reader consistent in the same frame
Interview Tips
- • "Why is computed cached but a method isn't?" — the getter runs inside the reactivity system, so Vue records its dependencies and invalidates precisely; a method is opaque, so the only safe strategy is calling it every render
- • "When does the computed getter actually re-run?" — say 'lazily, at the next read, after a tracked dependency changed'; mention the dirty flag — candidates who say 'on every render' lose the point of the topic
- • "What's wrong with computed(async ...) ?" — the cache stores the Promise forever; give the fix (ref + watchEffect with cleanup, or async setup under Suspense)
- • "How would you show the cart total in three components consistently?" — one shared computed (store getter / composable over shared state), never local copies; note that reads in the same tick all see the same dependency snapshot
Cheat Sheet
Computed Values
Shape:
const full = computed(() => `${first.value} ${last.value}`) // read-only ref
full.value // script
{{ full }} // template — top-level bindings auto-unwrap
Cache lifecycle: call → lazy (nothing runs) → first read runs getter + registers deps → dependency mutates → computed marked dirty (still no run) → next read recomputes once → cached again.
Method vs computed:
| Method in template | computed | |
|---|---|---|
| Runs | every render | only after a tracked dep changes |
| Result | recomputed each time | cached |
| Deps known to Vue | no | yes |
Contract — break it and here's what dies:
| Violation | Failure | Fix |
|---|---|---|
| async getter | caches a Promise; never refetches | ref + watchEffect / async setup + Suspense |
| side effects / self-mutation | 'Maximum recursive updates exceeded', 0-or-many invocations | effects belong in watch |
snapshot (total.value kept) |
frozen value, classic detach bug | keep the ref, read .value at use time |
Safe: conditional reads inside the getter — deps are re-collected every run (unlike watchEffect's tracking window).
Sharing: one computed instance (module scope or store getter) → one cache → every component agrees; computeds compose (computed over computed, invalidation propagates lazily).