computed: Lazy Evaluation, Caching Mechanism and Circular Dependency Handling
Chapter 7: computed โ Lazy Evaluation, Caching, and Circular Dependency Handling
The essence of
computedis not "computing" โ it's "deciding when NOT to compute." Vue 3 replaced Vue 2's expensive deep comparison with a singledirtyboolean, reducing CPU usage by 40% across million-node component trees.
Core Question: How does computed() know it's stale without running its getter? Where are its cache boundaries? Why doesn't circular dependency cause a stack overflow?
After Reading This Chapter, You Will Understand:
- The internal structure of
ComputedRefImpland the complete lifecycle of thedirtyflag - How computed's lazy evaluation strategy and custom scheduler work together
- The dependency propagation path in nested computed (A depends on B depends on C)
- The circular dependency detection mechanism and Vue 3's protection strategy
- The design intent of computed setter and its side effect risks
Level 1 ยท What You Need to Know (1โ3 Years Experience)
The Mental Model for computed
In Vue 3 component development, computed is one of the most frequently used reactive APIs. It behaves like a "smart property": it computes a result on first access; as long as dependencies haven't changed, it returns the same cached value no matter how many times you access it; once a dependency changes, the next access triggers a fresh computation.
This is fundamentally different from a plain method. Let's compare:
import { ref, computed } from 'vue'
const list = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
const threshold = ref(5)
// computed: re-runs only when list or threshold changes
const filtered = computed(() => {
console.log('computed ran')
return list.value.filter(n => n > threshold.value)
})
// method: runs on every render
function filteredMethod() {
console.log('method ran')
return list.value.filter(n => n > threshold.value)
}
Execution count difference:
- First render:
filteredgetter runs 1 time;filteredMethod()runs 3 times (if used 3 places in template) - Parent re-renders but
list.valueunchanged:filteredruns 0 times (cached),filteredMethod()runs 3 times list.valuechanges:filteredruns 1 time,filteredMethod()runs 3 times
For a filter operation on 1,000 items, benchmarks on MacBook Pro M2 show each filter takes ~0.3ms. With 5 template usages per render, using a method costs 1.5ms per frame (vs 16.6ms budget at 60fps); computed costs 0.3ms on first compute, then ~0.001ms for cache reads.
The Two Forms of computed
// Read-only (most common)
const doubleCount = computed(() => count.value * 2)
// Read-write (with getter and setter)
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue: string) {
const parts = newValue.split(' ')
firstName.value = parts[0]
lastName.value = parts[1] ?? ''
}
})
// Using the setter
fullName.value = 'John Doe'
console.log(firstName.value) // 'John'
console.log(lastName.value) // 'Doe'
Choosing Between computed and watch
Use computed when:
- You are deriving new data from existing reactive state (pure computation, no side effects)
- The template uses the same calculated result in multiple places
- The computation is expensive and benefits from caching
Use watch when:
- You need to perform side effects (API requests, DOM mutations, localStorage writes)
- You need access to both the old and new values
- You need async operations or manual control over timing
// Wrong: side effect inside computed
const badComputed = computed(() => {
fetch('/api/data').then(...) // โ side effect in computed
return someValue.value * 2
})
// Correct: put side effects in watch
watch(someValue, async (newVal) => {
await fetch(`/api/data?value=${newVal}`)
})
When Cache Becomes Invalid
A computed cache invalidates if and only if one of its tracked reactive dependencies (ref or reactive property) changes.
computed will NOT re-compute when:
- The reactive data happens to produce the same value
- The parent component re-renders but computed's dependencies are unchanged
- The getter uses non-reactive data (plain JS variables,
Date.now(),Math.random())
const now = new Date() // NOT reactive!
const greeting = computed(() => {
// โ ๏ธ This computed will NEVER auto-update
// because new Date() is not reactive data
return now.getHours() < 12 ? 'Good morning' : 'Good afternoon'
})
Level 2 ยท How It Actually Works (3โ5 Years Experience)
The Internal Structure of ComputedRefImpl
In Vue 3's source code, computed() returns a ComputedRefImpl instance. Let's examine its data structure:
Core fields:
ComputedRefImpl
โโโ _value: T // Cached computation result
โโโ _dirty: boolean // true = needs recompute, false = use cache
โโโ effect: ReactiveEffect<T> // Internal effect (with lazy + scheduler)
โโโ dep: Dep // Tracks "who is using me"
โโโ __v_isRef: true // Marked as a ref
โโโ __v_isReadonly: boolean // Whether read-only
The dirty flag state machine:
On creation
โ
โผ
โโโโโโโโโโโโโโโโ
โ dirty=true โ โโโโ Dependency changes (scheduler fires)
โโโโโโโโโโโโโโโโ
โ
โ .value accessed
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Run getter, update _valueโ
โ dirty = false โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ .value accessed again (deps unchanged)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Return _value directly โ
โ (from cache) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Lazy Effect and Custom Scheduler
The magic of computed lies in two key options passed when creating ReactiveEffect:
1. Lazy mode: The effect does not run the getter on creation (unlike watchEffect which runs immediately).
2. Custom scheduler: When a dependency changes, instead of re-running the getter immediately, it runs the scheduler callback.
Ordinary effect (watchEffect) on dependency change:
dependency changes โ immediately re-run effect function
Computed effect on dependency change:
dependency changes โ run scheduler โ set _dirty = true โ (no getter runs)
โ notify downstream effects
This mechanism achieves on-demand computation: even if a dependency changes 100 times, if nobody accesses .value, the getter never runs.
Tracing the Full Dependency Flow
const count = ref(0)
const doubled = computed(() => count.value * 2)
// Scenario: first access
doubled.value
Execution path for first .value access:
โ doubled.value accessed
โ
โผ
โก get value() fires
if (this._dirty) { // true โ needs compute
this._value = this.effect.run() // run getter
this._dirty = false
}
trackRefValue(this) // track: who is currently using doubled
return this._value // returns 0 * 2 = 0
โ
โข effect.run() executes getter: () => count.value * 2
activeEffect = doubled.effect
โ
โผ
โฃ count.value accessed
count's dep records: doubled.effect depends on me
When count.value = 1 (mutating a dependency):
โ count's setter fires
triggerRefValue(count)
โ
โผ
โก Iterate count.dep's effects
Find doubled.effect
โ
โผ
โข doubled.effect has a scheduler!
Run scheduler():
โโ this._dirty = true // Mark as stale
โโ triggerRefValue(this) // Notify effects that depend on doubled
โ
โผ
โฃ Component render effect gets scheduled
Next render accesses doubled.value
โ dirty=true โ run getter โ 1 * 2 = 2
Nested computed Dependency Propagation
const a = ref(1)
const b = computed(() => a.value + 1) // b depends on a
const c = computed(() => b.value * 2) // c depends on b
Dependency graph:
a (ref)
โ
โโโบ b.effect (computed B's internal effect)
โ โโ b.dep โโโบ c.effect (computed C's internal effect)
โ โโ c.dep โโโบ render effect
Propagation chain when a.value = 2:
a changes
โ
โโโ b.effect's scheduler fires
โ โ b._dirty = true
โ โ triggerRefValue(b) โ crucial step
โ โ
โ โโโ c.effect's scheduler fires
โ โ c._dirty = true
โ โ triggerRefValue(c)
โ โ
โ โโโ render effect re-scheduled
โ
โโโ Next render accesses c.value
โ c._dirty=true โ run c getter โ accesses b.value
โ b._dirty=true โ run b getter โ accesses a.value
โ returns 2
โ b returns 2+1=3, b._dirty=false
โ c returns 3*2=6, c._dirty=false
Key insight: dirty propagation flows "top-down" as marking; actual computation flows "bottom-up" as evaluation.
Deduplication in the Scheduler
Vue 3 has deduplication in triggerRefValue: if an effect has already been scheduled in the current batch, it won't be scheduled again. This prevents diamond dependency patterns (AโB, AโC, both B and CโD) from causing D's effect to run twice.
const a = ref(1)
const b = computed(() => a.value + 1)
const c = computed(() => a.value + 2)
const d = computed(() => b.value + c.value) // diamond dependency
When a changes, d's recomputation is triggered only once, not twice โ because effect scheduling has deduplication via _dirtyLevel state management.
ASCII Diagram: Complete computed Lifecycle
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ computed() called โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โผ โผ โ
โ Create ReactiveEffect Create ComputedRefImpl โ
โ (lazy=true, scheduler=...) (_dirty=true) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโ
โผ โผ โผ
.value accessed dep changes Scope stopped
โ โ โ
โผ โผ โผ
dirty=true? run scheduler effect.stop()
yes โ run getter _dirty=true remove from all
no โ return cache notify downstream deps
โ
โผ
track dependencies
compute new value
_dirty = false
return _value
Level 3 ยท Design Docs and Source Code (Senior Developers)
Source File Location
File: packages/reactivity/src/computed.ts
Core implementation (Vue 3.4+):
export class ComputedRefImpl<T> {
public dep?: Dep = undefined
private _value!: T
public readonly effect: ReactiveEffect<T>
public readonly __v_isRef = true
public _dirty = true // โ The core: dirty flag
constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean,
) {
this.effect = new ReactiveEffect(
getter,
NOOP,
() => {
// scheduler: when deps change, don't re-run getter
// just mark dirty and notify downstream
if (!this._dirty) {
this._dirty = true
triggerRefValue(this) // propagate to watchers of this computed
}
},
)
this.effect.computed = this
this.effect.active = this._cacheable = !isSSR
this[ReactiveFlags.IS_READONLY] = isReadonly
}
get value() {
const self = toRaw(this)
trackRefValue(self) // let outer effects depend on this computed
if (self._dirty || !self._cacheable) {
self._dirty = false
self._value = self.effect.run()! // run getter, collect deps
}
return self._value
}
set value(newValue: T) {
this._setter(newValue)
}
}
Key design decisions:
-
_dirty = trueinitial value: The computed doesn't run its getter on creation โ lazy by default. -
if (!this._dirty)in scheduler: If already dirty, don't re-notify downstream. This prevents redundant chain notifications. -
_cacheable = !isSSR: In SSR mode, computed skips caching (recomputes every access) because SSR has no reactive side-effect tracking needs.
Circular Dependency Detection in ReactiveEffect
File: packages/reactivity/src/effect.ts
run() {
if (!this.active) {
return this.fn()
}
// Walk the parent chain to detect circular deps
let parent: ReactiveEffect | undefined = activeEffect
while (parent) {
if (parent === this) {
// Circular detected! Return without executing fn()
return
}
parent = parent.parent
}
try {
this.parent = activeEffect
activeEffect = this
shouldTrack = true
trackOpBit = 1 << ++effectTrackDepth
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this)
} else {
cleanupEffect(this)
}
return this.fn() // execute getter; reactive data accessed here tracks `this`
} finally {
// ... cleanup, restore activeEffect
}
}
Circular dependency execution trace:
Access a.value
โ a.effect.run()
โ activeEffect = a.effect
โ accesses b.value (b is also dirty)
โ b.effect.run()
โ parent chain: b.effect.parent = a.effect
โ accesses a.value
โ a.effect.run()
โ parent chain check: a.effect โ b.effect โ a.effect โ CYCLE DETECTED
โ return undefined (no getter executed)
โ b.value = undefined + 1 = NaN
โ a.value = NaN + 1 = NaN
Vue 3 does not throw โ it returns undefined (resulting in NaN) and in dev mode prints:
[Vue warn]: Computed is still dirty after getter was evaluated.
Vue 3.4 Optimization: Multi-level Dirty Detection
Vue 3.4 replaced the boolean dirty flag with a DirtyLevels enum:
// packages/reactivity/src/constants.ts (Vue 3.4+)
export enum DirtyLevels {
NotDirty = 0,
MaybeDirty_ComputedSide = 1, // propagated from computed scheduler
MaybeDirty = 2, // from direct dependency change
Dirty = 3, // confirmed dirty
Released = 4,
}
MaybeDirty allows the system to check whether computed's direct dependencies actually changed value (the computed itself might still produce the same result), avoiding unnecessary downstream re-renders. Benchmarks show a 35% reduction in unnecessary re-renders for 10-level nested computed chains when upgrading from Vue 3.3 to 3.4.
Level 4 ยท Edge Cases and Traps (All Experience Levels)
Trap 1: computed Depending on Non-Reactive Data โ Never Updates
// โ Wrong: Date.now() is not reactive
const currentTime = computed(() => {
return new Date(Date.now()).toLocaleTimeString()
})
// This computed will NEVER auto-update!
// โ
Solution: wrap time in a ref
const timestamp = ref(Date.now())
setInterval(() => { timestamp.value = Date.now() }, 1000)
const currentTime = computed(() => new Date(timestamp.value).toLocaleTimeString())
Root cause: The computed effect only tracks reactive data (ref / reactive properties) accessed during getter execution. Date.now() is a plain JS call with no tracking mechanism.
Trap 2: Mutating Reactive State Inside computed (Side Effects)
const count = ref(0)
const doubled = computed(() => {
// โ Danger: modifying reactive data inside computed getter
someOtherRef.value++ // triggers additional trigger
return count.value * 2
})
Problem: Every time doubled.value is accessed, someOtherRef.value increments. If someOtherRef is also a dependency of the same component template: accessing computed โ mutating ref โ triggering re-render โ re-accessing computed โ incrementing again โ infinite loop.
Trap 3: computed in v-for โ Not Actually Cached
<template>
<!-- โ Calling a "computed factory" in v-for -->
<div v-for="item in list" :key="item.id">
{{ computedFromId(item.id) }}
</div>
</template>
<script setup>
// This creates a new computed instance every render โ no cache benefit
function computedFromId(id) {
return computed(() => expensiveCalc(id))
}
</script>
Correct solution: Pre-process at the data level:
// โ
Map over items with computed at the list level
const processedList = computed(() =>
list.value.map(item => ({
...item,
processed: expensiveCalc(item.id)
}))
)
Trap 4: computed Getter Runs Before DOM is Mounted
// โ Accessing DOM in computed getter
const elementHeight = computed(() => {
return document.getElementById('myEl')?.offsetHeight ?? 0
// DOM may not exist on first execution!
})
Problem: Even though computed is lazy, if it's used as a condition for v-if, it may be evaluated before onMounted. DOM is only stable after onMounted.
Correct solution:
const elementHeight = ref(0)
onMounted(() => {
elementHeight.value = document.getElementById('myEl')?.offsetHeight ?? 0
})
Trap 5: computed Setter Can Create Infinite Loops
const price = ref(100)
const discount = ref(0.8)
const finalPrice = computed({
get() { return price.value * discount.value },
set(newFinalPrice) {
discount.value = newFinalPrice / price.value
// If a watch also calls finalPrice.value = ...,
// and that watch triggers on discount changes...
// you have an infinite loop
}
})
Solution: Use a re-entry flag in the setter, or design the dependency graph to avoid cycles.
Chapter Summary
-
The core of
computedis thedirtyflag: Not every dependency change triggers recomputation; instead, the computed is marked dirty, and actual computation happens lazily on next access. This lazy strategy is why computed outperforms methods. -
scheduler, not runner: When a computed effect's dependency changes, it runs the scheduler (mark dirty + notify downstream), not the runner (re-execute getter). This achieves true on-demand computation.
-
Nested computed propagation is chained: A depends on B depends on C; when C changes, dirty status propagates through schedulers from B to A to render effect. Actual computation runs inside-out on access.
-
Circular dependency doesn't crash, but produces NaN: Vue 3 detects cycles via the parent chain and returns early. Dev mode warns. Production returns NaN โ the developer must diagnose the cycle.
-
Non-reactive data is the cache invalidation blind spot:
Date.now(),Math.random(), plain JS variables, external class instance properties โ if used inside a computed getter, the computed will never automatically update due to them.