DEV Community

Cover image for How Computed Values Really Work: Lazy Evaluation in a Reactive Graph
Luciano0322
Luciano0322

Posted on

How Computed Values Really Work: Lazy Evaluation in a Reactive Graph

Recap

Before we continue, let’s quickly review what we already have:

1. graph.ts

Node { kind, deps, subs }, link / unlink, withObserver / track
(reused from Implementing Effect (I))

2. EffectRegistry

Effects.get / set / delete(node: Node)
(reused from Implementing Effect (II))

3. signal.ts

set() iterates over subs, and for effects calls
Effects.get(sub)?.schedule()
(we’ll add a branch for computed below)


What Kind of “Memo” Is This computed?

TL;DR

This computed is equivalent to Vue’s computed or Solid’s createMemo not React’s useMemo.

It is a node in the reactive dataflow graph that:

  • Automatically tracks dependencies
  • Recomputes lazily (only when read)
  • Is not bound to any component lifecycle

Comparison Table

Aspect This article’s computed (signals) Vue computed Solid createMemo React useMemo
Dependency declaration Automatic (read = subscribe) Automatic Automatic Manual deps array
When it computes Lazy, recompute only when stale and read Lazy Lazy During render
Lifecycle Independent of components Tied to instance Tied to root Bound to render
Role in dataflow graph Observer + Trackable (deps + subs) Same Same Not a reactive node
Suitable for side effects No No No No

Examples

Vue

const a = ref(1);
const b = ref(2);
const sum = computed(() => a.value + b.value);
Enter fullscreen mode Exit fullscreen mode

Solid

const [a] = createSignal(1);
const [b] = createSignal(2);
const sum = createMemo(() => a() + b());
Enter fullscreen mode Exit fullscreen mode

This Article (signals)

const a = signal(1);
const b = signal(2);
const sum = computed(() => a.get() + b.get()); // auto-tracked, lazy
Enter fullscreen mode Exit fullscreen mode

React (for contrast only)

const [a, setA] = useState(1);
const [b, setB] = useState(2);

// Render-time cache only; not part of the reactive graph
const sum = useMemo(() => a + b, [a, b]);
Enter fullscreen mode Exit fullscreen mode

The computed we’re about to implement is the kind that:

  • Computes only when read
  • Caches the result
  • Has explicit upstream/downstream edges in the graph.

Design Considerations

  • A computed is both:
    • An Observer (it depends on others → has deps)
    • And a Trackable (others depend on it → has subs)
  • Two internal states:
    • stale: boolean — dirty flag
    • computing: boolean — reentrancy guard (cycle detection)
  • equals(a, b) — skip cache updates when values are equal (default: Object.is)

Who Depends on Whom?

In the diagram below, arrows mean “I depend on this.”

A computed sits in the middle: it has both upstream and downstream edges.

  • signal: only depended on (no deps)
  • effect: only depends on others (no subs)
  • computed: both sides

Push vs Pull: From set() to Recompute

  • signal.set() only pushes invalidation:
    • marks computed as stale,
    • schedules effects via the registry.
  • Actual computation happens later, when computed.get() is called.

Key points:

  • EffectRegistry: maps Node → EffectInstance (via Symbol or WeakMap)
  • Microtask batching: multiple set() calls in the same tick trigger only one effect run.

Lazy Evaluation Flow of computed.get()

  • Only when read do we check stale
  • If dirty:
    • Unlink old dependencies
    • Recompute inside a tracking context
    • Cache the result

Push Invalidation, Pull Computation

Push

signal.set()
→ mark computed as stale
→ schedule effects

Pull

computed.get()
→ recompute only if stale

Cycle protection

reading itself while computing throws an error.


Implementing computed

// computed.ts
import { link, unlink, withObserver, track, type Node } from "./graph";
import { SymbolRegistry as Effects } from "./registry";

type Comparator<T> = (a: T, b: T) => boolean;
const defaultEquals = Object.is;

/** Used by signal.set() and other computed nodes to mark dirty */
export function markStale(node: Node) {
  if (node.kind !== "computed") return;
  const c = node as Node & { stale: boolean };
  if (c.stale) return; // already dirty
  c.stale = true;

  // Propagate downstream
  for (const sub of node.subs) {
    if (sub.kind === "computed") {
      markStale(sub); // cascade to downstream computed nodes
    } else if (sub.kind === "effect") {
      Effects.get(sub)?.schedule(); // enqueue effect
    }
  }
}

export function computed<T>(
  fn: () => T,
  equals: Comparator<T> = defaultEquals
) {
  const node: Node & {
    kind: "computed";
    value: T;
    stale: boolean;
    equals: Comparator<T>;
    computing: boolean;
    hasValue: boolean;
  } = {
    kind: "computed",
    deps: new Set(),
    subs: new Set(),
    value: undefined as unknown as T,
    stale: true, // compute on first read
    equals,
    computing: false,
    hasValue: false,
  };

  function recompute() {
    if (node.computing) throw new Error("Cycle detected in computed");
    node.computing = true;

    // Remove old dependencies (avoid dependency drift leaks)
    for (const d of [...node.deps]) unlink(node, d);

    // Track new dependencies during execution
    const next = withObserver(node, fn);

    if (!node.hasValue || !node.equals(node.value, next)) {
      node.value = next;
      node.hasValue = true;
    }
    node.stale = false;
    node.computing = false;
  }

  const get = () => {
    track(node); // allow observers to subscribe
    if (node.stale || !node.hasValue) recompute();
    return node.value;
  };

  const peek = () => node.value;

  const dispose = () => {
    // Remove all upstream and downstream links
    for (const d of [...node.deps]) unlink(node, d);
    for (const s of [...node.subs]) unlink(s, node);
    node.deps.clear();
    node.subs.clear();
    node.stale = true;
    node.hasValue = false;
  };

  // peek, dispose, and _node are for testing convenience
  return { get, peek, dispose, _node: node };
}
Enter fullscreen mode Exit fullscreen mode

Wiring computed into signal.set()

Add a computed branch in the subscription loop:

// signal.ts
import { markStale } from "./computed";

// inside set()
for (const sub of node.subs) {
  if (sub.kind === "effect") {
    Effects.get(sub)?.schedule();
  } else if (sub.kind === "computed") {
    markStale(sub); // mark dirty only, no immediate recompute
  }
}

Enter fullscreen mode Exit fullscreen mode

This gives us:

  • Push invalidation (dirty marking)
  • Pull computation (lazy recompute on read)

Effects are scheduled, but real recomputation only happens when computed.get() is called inside the effect callback.


Usage Example

const a = signal(1);
const b = signal(2);

const sum = computed(() => a.get() + b.get());   // 3
const double = computed(() => sum.get() * 2);    // 6

const stop = createEffect(() => {
  console.log("double =", double.get());
  onCleanup(() => console.log("cleanup"));
});

a.set(5); 
// marks sum & double as stale; schedules effect
// microtask: cleanup → "double = 14" (recompute happens here)

stop();
Enter fullscreen mode Exit fullscreen mode

Conclusion

So far, we’ve completed:

  • signal.set() Push invalidation — mark computed as stale, schedule effects
  • computed.get() Pull computation — recompute lazily in a tracking context
  • EffectRegistry Node → EffectInstance lookup (via Symbol or WeakMap)

In the next article, we’ll introduce Batching & Transactions to eliminate unnecessary repeated updates.

Top comments (0)