Chapter 7

computed: Lazy Evaluation, Caching Mechanism and Circular Dependency Handling

Chapter 7: computed โ€” Lazy Evaluation, Caching, and Circular Dependency Handling

The essence of computed is not "computing" โ€” it's "deciding when NOT to compute." Vue 3 replaced Vue 2's expensive deep comparison with a single dirty boolean, 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:


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:

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:

  1. You are deriving new data from existing reactive state (pure computation, no side effects)
  2. The template uses the same calculated result in multiple places
  3. The computation is expensive and benefits from caching

Use watch when:

  1. You need to perform side effects (API requests, DOM mutations, localStorage writes)
  2. You need access to both the old and new values
  3. 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:

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:

  1. _dirty = true initial value: The computed doesn't run its getter on creation โ€” lazy by default.

  2. if (!this._dirty) in scheduler: If already dirty, don't re-notify downstream. This prevents redundant chain notifications.

  3. _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

  1. The core of computed is the dirty flag: 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Rate this chapter
4.6  / 5  (52 ratings)

๐Ÿ’ฌ Comments