Vue computed(): What It Caches and When It Reruns
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. What You'll Learn 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() , andwatchEffect() for a given job - Spot the two most common ways developers accidentally break computed caching Who This Is For You've written a Vue 3 component with 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. Table of Contents - The Problem: Recomputing What You Already Computed - The Mental Model: A Lazy, Cached Effect - Building It Up: How computed() Actually Behaves - Edge Cases and Gotchas - Best Practices - FAQ - Cheat Sheet - Key Takeaways 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. The Problem: Recomputing What You Already Computed Say you're rendering a cart summary and you need a formatted total: 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) } Total: {{ formattedTotal() }} Total (again): {{ formattedTotal() }} Total (once more): {{ formattedTotal() }} 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 Lazy, Cached Effect 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. Building It Up: How computed() Actually Behaves Stage 1: The Cache Is Real - Prove It With a Counter 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 }) {{ sum }} - {{ sum }} - {{ sum }} Getter ran {{ getterRuns }} time(s) 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. Stage 2: Invalidation Is Synchronous, Recomputation Is Lazy 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: a.value++ a.value++ a.value++ console.log(sum.value) // getter runs exactly once here, using the final values Stage 3: Dependency Tracking Is Re-Done Every Run A computed's dependency list isn't fixed at creation - it's rebuilt on every evaluation, so branches matter: 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. Stage 4: Writable Computed A computed can accept writes by passing { get, set } instead of a bare function: 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. Stage 5: computed() vs watch() vs watchEffect() 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. Edge Cases and Gotchas - 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 aPromise , and Vue has no way to "await" a cache fill. For async derived state, usewatchEffect (orwatch ) to populate a plainref 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. UsetoRefs() (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 iscomputed(() => count.value % 2 === 0) andcount goes from2 to4 , the getter reruns but returns the sametrue , soisEven '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 variable used outside the template) - it doesn't need a component render as its consumer, any effect reading.value keeps the caching rule intact. Best Practices - 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 t
Comments
No comments yet. Start the discussion.