← blog
VueSeptember 30, 2026 · 12 min read

Vue computed(): What It Caches and When It Reruns

Vue's computed() caches its result and only reruns when a tracked dependency changes, not on every read. The exact caching and invalidation model, verified against Vue 3.5.

Parsa Jiravand · Frontend engineer · building bestpractic
Vue computed(): What It Caches and When It Reruns
  1. 1Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)14 min
  2. 2Vue nextTick Explained: How DOM Updates Are Batched12 min
  3. 3Vue Composables: The Shared State Trap (+ Cheat Sheet)13 min
  4. 4Vue computed(): What It Caches and When It Rerunsyou are here

You call a computed() getter fifty times in one render and it only runs once. You mutate a ref it depends on and — nothing happens, not yet. Then you read .value and it recomputes. If that sequence feels slightly magical, it's because computed() isn't a function you call. It's a cached, lazily-evaluated effect with its own subscription list, and once you see the actual rule, none of it is magic anymore.

By the end of this article you'll be able to:

  • Explain exactly when a computed() getter runs and when it doesn't
  • Tell the difference between a dependency changing and a computed recomputing
  • Predict how many times a getter actually executes in a given sequence of writes and reads
  • Choose correctly between computed(), watch(), and watchEffect() for a given job
  • Spot the two most common ways developers accidentally break computed caching

You've written a Vue 3 component with <script setup> and used ref, reactive, and computed at least once. This article assumes you know what a computed property looks like in code; it's here to fix how you think it behaves internally, which the syntax alone doesn't teach you. If the read-time tracking that makes any of this possible is still fuzzy, Vue Reactivity: ref vs reactive builds that foundation — useful background, not required reading.

This is written against Vue 3.5.x (verified 3.5.43 on npm, September 2026). Vue 3.6 is currently a release candidate adding an alien-signals-based reactivity core and an opt-in "Vapor Mode" compiler — neither changes the caching contract described here, so this article holds for both.

Say you're rendering a cart summary and you need a formatted total:

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup> import { ref } from 'vue' const items = ref([ { name: 'Keyboard', price: 89.5, qty: 1 }, { name: 'Mouse', price: 24.99, qty: 2 }, ]) function formattedTotal() { console.log('recalculating total…') const total = items.value.reduce((sum, i) => sum + i.price * i.qty, 0) return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(total) } </script> <template> <p>Total: {{ formattedTotal() }}</p> <p>Total (again): {{ formattedTotal() }}</p> <p>Total (once more): {{ formattedTotal() }}</p> </template>

formattedTotal is a plain method, so the template calls it every time it appears, and again on every re-render triggered by anything else in the component — a hover state, an unrelated ref, a parent re-rendering. The console fills with recalculating total… for work whose answer hasn't changed. It's correct, but it's wasteful, and the waste scales with how many places in the template read the value and how often the component re-renders for unrelated reasons.

The fix looks trivial — wrap it in computed() — but why that fixes it, and exactly what it changes, is the actual subject of this article.

The mental model: a computed() ref is a special reactive effect that (1) tracks whatever reactive state its getter reads, exactly like a component render or a watchEffect does, (2) caches the return value instead of re-running immediately when a dependency changes, and (3) only re-runs the getter the next time something reads .value after it has been marked dirty.

That's two separate events, not one:

  • Invalidation happens the instant a tracked dependency is written to. It's synchronous and cheap: Vue marks the computed dirty (in 3.5, a computed nobody subscribes to isn't even notified — it compares dependency version counters on its next read instead). Nothing is recomputed yet.
  • Recomputation happens lazily, on the next .value read, and only if the dirty flag is set. If nobody ever reads it again, the getter never reruns — you paid for a flag flip, not a recalculation.

This is the same pull-based laziness that makes computed cheaper than a method call under repeated reads, and it's also exactly the behavior that surprises people the first time they log something inside a getter and see it fire at a moment they didn't expect.

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup> import { ref, computed } from 'vue' const a = ref(2) const b = ref(3) let getterRuns = 0 const sum = computed(() => { getterRuns++ return a.value + b.value }) </script> <template> <p>{{ sum }} — {{ sum }} — {{ sum }}</p> <p>Getter ran {{ getterRuns }} time(s)</p> </template>

Even though the template reads sum three times in one render, getterRuns is 1. Key concept: the first read evaluates and caches; every subsequent read before an invalidation returns the cached value without touching the getter at all.

JavaScript
1
2
3
4
5
a.value = 10 // invalidation: dirty flag set, getter has NOT run console.log(getterRuns) // still 1 console.log(sum.value) // recomputation happens here console.log(getterRuns) // now 2

Key concept: writing to a doesn't run the getter — it only marks the cache stale. The getter runs on the next read, which might be immediately, might be several ticks later, or might never happen if nothing ever reads that computed again (say, the component that used it unmounted first).

This is also why mutating a dependency ten times in a row before anything reads the computed still only costs one recompute, not ten:

JavaScript
1
2
3
4
a.value++ a.value++ a.value++ console.log(sum.value) // getter runs exactly once here, using the final values

A computed's dependency list isn't fixed at creation — it's rebuilt on every evaluation, so branches matter:

JavaScript
1
2
const useB = ref(false) const result = computed(() => (useB.value ? b.value : a.value))

If useB is false, this run only reads useB and a — writing to b will not invalidate result at all, because b was never touched during the last evaluation. Flip useB to true and the next evaluation reads b instead, and from then on b is a real dependency and a is not. Key concept: the subscription list is a snapshot of what the getter actually read last time, not what it could theoretically read.

A computed can accept writes by passing { get, set } instead of a bare function:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
const first = ref('Ada') const last = ref('Lovelace') const fullName = computed({ get: () => `${first.value} ${last.value}`, set(value) { [first.value, last.value] = value.split(' ') }, }) fullName.value = 'Grace Hopper' // runs the setter, which writes first & last

The caching rule for the get side is identical to the read-only form. The set side isn't cached at all — it's a plain function call every time you assign.

All three are reactive effects that track dependencies the same way. What they're for is different:

  • computed() — derive a value from other reactive state, synchronously, with no side effects. Use it whenever the answer is "a new piece of data computed from data I already have."
  • watchEffect() — run a side effect (fetch, log, DOM measurement) and automatically track whatever reactive state it reads. Runs immediately on creation, then again whenever a tracked dependency changes.
  • watch() — run a side effect in response to specific sources you name explicitly, with access to both the old and new value, and control over timing (flush: 'post') and whether it fires immediately (immediate: true).

If you find yourself putting fetch(), console.log(), or a router push inside a computed() getter, that's the signal you wanted watch or watchEffect instead — a computed getter must stay pure, because Vue may call it, skip it, or defer it based on caching, and a getter with side effects makes those decisions unpredictable for you.

  • An impure getter breaks the contract. Because Vue decides when (or whether) to rerun a computed getter, any side effect inside it runs an unpredictable number of times — including zero, if nothing ever reads the computed after invalidation.
  • Async getters don't work. computed() expects a synchronous return value; an async function returns a Promise, and Vue has no way to "await" a cache fill. For async derived state, use watchEffect (or watch) to populate a plain ref yourself, or reach for a dedicated async-state composable.
  • Destructuring a reactive() object breaks tracking, and that silently breaks any computed built from the destructured variable, because there's no longer a reactive property to subscribe to. Use toRefs() (or read through the object) before destructuring.
  • Since Vue 3.4, a computed only propagates when its value actually changed, not merely when a dependency changed. If isEven is computed(() => count.value % 2 === 0) and count goes from 2 to 4, the getter reruns but returns the same true, so isEven's own dependents don't rerun — this specifically helps chains of computed-on-computed avoid cascading work when a dependency changed but the derived result didn't. As of 3.4 you can also read the previous result as the getter's first argument: computed((prev) => …).
  • A computed with no active subscriber can still be read directly (e.g. from a <script setup> variable used outside the template) — it doesn't need a component render as its consumer, any effect reading .value keeps the caching rule intact.

  • Reach for computed() whenever the value is "purely derived from state I already have" and doesn't need to log, fetch, or otherwise reach outside itself.
  • Reach for watch() when you need the previous value, need to react to one specific source, or need to control flush timing around DOM updates.
  • Reach for watchEffect() for a side effect whose dependencies you're happy to let Vue infer automatically, and that should also run once immediately.
  • Never mutate other reactive state from inside a computed getter. It works today by accident and turns into an infinite-invalidation bug the moment the mutated state is also one of the computed's own dependencies.
  • Keep computed getters cheap enough that recomputing occasionally is fine — the cache saves you from repeated reads between invalidations, not from the cost of a single run.

No. It reruns only on a read that happens after a tracked dependency has changed since the last evaluation. Reading the same, non-invalidated computed any number of times returns the cached value.

Almost always one of two reasons: the property you changed was never read during the getter's last run (so it isn't a tracked dependency — see Stage 3), or you mutated a plain destructured variable instead of the reactive source itself. A third, rarer one: the getter reads something that isn't reactive at all — computed(() => Date.now()) never updates.

No — the getter must return a value synchronously. Model async derived state with a plain ref updated from inside watchEffect or watch instead.

It's memoization plus an automatic, fine-grained invalidation signal — you don't hand it a dependency array like useMemo; it discovers its dependencies by watching what the getter reads.

No. The cached value and the dirty flag live on the computed ref itself, shared by every reader — the getter still runs at most once per invalidation, regardless of how many places read .value.

SituationWhat happens
Read computed.value repeatedly, no writes betweenGetter runs once; every later read returns the cache
Write to a tracked dependencyMarks the computed dirty synchronously; getter does not run yet
Read .value after a dirty markGetter runs now, result re-cached
Write to a dependency multiple times before any readStill exactly one recompute, on the next read
Write to a property the getter didn't read last runNo invalidation — it isn't a tracked dependency
Getter branches on a conditionOnly the branch actually taken this run is tracked
Need previous valuecomputed((prev) => …) or computed({ get(prev) {…}, set }) (Vue 3.4+)
Need to write to itcomputed({ get, set })
Need a side effect, not a derived valuewatch() or watchEffect(), not computed()
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { ref, computed } from 'vue' const price = ref(20) const qty = ref(3) // Derived, pure, cached — recomputes only when price or qty change // and something actually reads .value afterward. const total = computed(() => price.value * qty.value) // Writable computed const discountedTotal = computed({ get: () => total.value * 0.9, set(value) { price.value = value / 0.9 / qty.value }, })

  • computed() caches its result and reevaluates lazily — invalidation (a dependency write) and recomputation (the next read) are two separate moments, not one.
  • Its dependency list is rebuilt every run, so conditional branches inside the getter can add or drop dependencies between evaluations.
  • Since Vue 3.4, a computed only propagates to its dependents when the recomputed value actually differs from the last one.
  • Keep computed getters pure and synchronous; reach for watch() or watchEffect() the moment you need a side effect or async work.

That console full of recalculating total… from the opening example disappears the moment formattedTotal() becomes a computed() — not because Vue got smarter about when to call it, but because you stopped calling it directly at all. You're reading a cache that knows, dependency by dependency, exactly when it's stale. Once you can picture that dirty flag and the lazy pull that clears it, computed properties stop being a syntax you memorized and become a piece of Vue's design you can reason about.

If you've been burned by a computed that "wouldn't update," what turned out to be the actual dependency it was missing? Drop it in the comments — it's almost always Stage 3 in disguise.

Runs right in your browser — poke at it and watch the concept react live.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.