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.