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
computedis equivalent to Vue’scomputedor Solid’screateMemonot React’suseMemo.
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);
Solid
const [a] = createSignal(1);
const [b] = createSignal(2);
const sum = createMemo(() => a() + b());
This Article (signals)
const a = signal(1);
const b = signal(2);
const sum = computed(() => a.get() + b.get()); // auto-tracked, lazy
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]);
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
computedis both:- An Observer (it depends on others → has
deps) - And a Trackable (others depend on it → has
subs)
- An Observer (it depends on others → has
- 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 (nodeps) -
effect: only depends on others (nosubs) -
computed: both sides
Push vs Pull: From set() to Recompute
-
signal.set()only pushes invalidation:- marks
computedas stale, - schedules effects via the registry.
- marks
- Actual computation happens later, when
computed.get()is called.
Key points:
-
EffectRegistry: maps
Node → EffectInstance(viaSymbolorWeakMap) -
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 };
}
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
}
}
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();
Conclusion
So far, we’ve completed:
-
signal.set()Push invalidation — markcomputedas stale, schedule effects -
computed.get()Pull computation — recompute lazily in a tracking context -
EffectRegistryNode → EffectInstance lookup (viaSymbolorWeakMap)
In the next article, we’ll introduce Batching & Transactions to eliminate unnecessary repeated updates.
Top comments (0)