Introduction
In the previous article, we finished a Signal core that supports subscriptions. In this one, we’ll implement Effect, so each dependency can be tracked automatically—turning a previously static graph into a reactive one that truly “moves.”
Goals of this article
-
createEffect(fn): Runfninside a tracking scope and automatically collect dependency edges. -
signal.set(): Notify dependent effects and re-run them once via microtask batching. -
onCleanup(cb)anddispose(): Clean up before re-runs, and allow manual disposal (unsubscribe).
Core class relationships (with the Registry abstraction)
Let’s establish the role boundaries with this diagram:
-
Node: A single node model, distinguished bykind(signal/computed/effect). -
EffectInstance: Owns an effect node and is responsible forrun/schedule/dispose. -
EffectRegistry: An abstraction interface. BothSymbolRegistryandWeakMapRegistryimplement it (Part 2 will demonstrate switching).
EffectInstance owns the node (Node). The registry is just a “lookup table” that lets us recover the instance from the node.
The createEffect construction flow
This diagram shows construction and the first execution:
- Construct an
EffectInstance. - Register “node → instance” into the registry.
- First
run()executes within a tracking scope to collect dependencies (build the graph). Notification is not the focus yet.
Graph building comes before notification. The behavioral core of this article is: effects can re-run. Graph maintenance (collecting and detaching deps) is handled internally in run().
How a signal notifies an effect (via the registry)
This diagram walks through “signal update → re-run” end-to-end:
-
signal.set()updates the value. - Iterate
subs(who subscribed to this signal). - If the subscriber is an effect node, retrieve the corresponding instance from the registry and call
schedule().
The registry is only a lookup. The real work happens inside EffectInstance.schedule(), which batches via microtasks and then calls run().
Recap: the base model
Single node + tracking utility
// Previous article model
// graph.ts
export type Kind = 'signal' | 'computed' | 'effect';
export interface Node {
kind: Kind;
deps: Set<Node>; // who I depend on (effect / computed)
subs: Set<Node>; // who depends on me (signal / computed)
}
export function link(from: Node, to: Node) {
if (from.kind === 'signal') throw new Error('Signal nodes cannot depend on others');
from.deps.add(to);
to.subs.add(from);
}
export function unlink(from: Node, to: Node) {
from.deps.delete(to);
to.subs.delete(from);
}
// Tracking: reads under an observer context automatically create an edge Observer -> Trackable
let currentObserver: Node | null = null;
export function withObserver<T>(obs: Node, fn: () => T): T {
const prev = currentObserver;
currentObserver = obs;
try {
return fn();
} finally {
currentObserver = prev;
}
}
export function track(dep: Node) {
if (!currentObserver) return;
link(currentObserver, dep);
}
Effect Registry abstraction
In this article we’ll start with a Symbol-based registry (Part 2 will switch to a WeakMap-based version):
// registry.ts
import type { Node } from "./graph.js";
export interface EffectInstanceLike {
schedule(): void;
}
export const EffectSlot: unique symbol = Symbol("EffectSlot");
export interface EffectCarrier {
[EffectSlot]?: EffectInstanceLike;
}
export interface EffectRegistry {
get(node: EffectCarrier): EffectInstanceLike | undefined;
set(node: EffectCarrier, inst: EffectInstanceLike): void;
delete(node: EffectCarrier): void;
}
export const SymbolRegistry: EffectRegistry = {
get(node) {
return node[EffectSlot];
},
set(node, inst) {
Object.defineProperty(node, EffectSlot, {
value: inst,
enumerable: false,
configurable: true
});
},
delete(node) {
Reflect.deleteProperty(node, EffectSlot);
}
};
The registry’s job is to maintain the Node → EffectInstance relationship, so signal.set() can find the effect to re-run in O(1) time.
Effect
Batch scheduling, re-running, and cleanup (depending on the registry abstraction, not a concrete implementation):
// effect.ts
import { unlink, withObserver, type Node } from "./graph.js";
import { SymbolRegistry, type EffectInstanceLike } from "./registry.js";
type Cleanup = () => void;
// Shared helper: execute cleanups in LIFO order and ensure the list is cleared
function drainCleanups(list: Cleanup[], onError?: (err: unknown) => void) {
// LIFO: run from tail to head
for (let i = list.length - 1; i >= 0; i--) {
const cb = list[i];
try {
cb();
} catch (e) {
onError?.(e);
}
}
list.length = 0;
}
// Microtask batching
const pending = new Set<EffectInstance>();
let scheduled = false;
function schedule(inst: EffectInstance) {
if (inst.disposed) return;
pending.add(inst);
if (!scheduled) {
scheduled = true;
queueMicrotask(() => {
scheduled = false;
const list = Array.from(pending);
pending.clear();
for (const ef of list) ef.run();
});
}
}
let activeEffect: EffectInstance | null = null;
export function onCleanup(cb: Cleanup) {
if (activeEffect) activeEffect.cleanups.push(cb);
}
export class EffectInstance implements EffectInstanceLike {
node: Node = {
kind: 'effect',
deps: new Set(),
subs: new Set()
};
cleanups: Cleanup[] = [];
disposed = false;
constructor(private fn: () => void | Cleanup) {
SymbolRegistry.set(this.node, this); // Only touch the registry
}
run() {
if (this.disposed) return;
// 1) cleanup from last run
drainCleanups(this.cleanups);
// 2) detach old dependencies
for (const dep of [...this.node.deps]) unlink(this.node, dep);
// 3) run inside tracking scope to collect new deps; support returning a cleanup
activeEffect = this;
try {
const ret = withObserver(this.node, this.fn);
if (typeof ret === 'function') this.cleanups.push(ret);
} finally {
activeEffect = null;
}
}
schedule() { schedule(this); }
dispose() {
if (this.disposed) return;
this.disposed = true;
drainCleanups(this.cleanups);
for (const dep of [...this.node.deps]) unlink(this.node, dep);
this.node.deps.clear();
SymbolRegistry.delete(this.node); // Only touch the registry
}
}
export function createEffect(fn: () => void | Cleanup) {
const inst = new EffectInstance(fn);
inst.run(); // Run once first to collect deps
return () => inst.dispose();
}
Signal
Notify dependent effects inside set():
// signal.ts
import { link, track, unlink, type Node } from "./graph.js";
import { SymbolRegistry } from "./registry.js";
type Comparator<T> = (a: T, b: T) => boolean;
const defaultEquals = Object.is;
export function signal<T>(initial: T, equals: Comparator<T> = defaultEquals) {
const node: Node & { kind: 'signal'; value: T; equals: Comparator<T> } = {
kind: 'signal',
deps: new Set(), // guaranteed empty by link rules
subs: new Set(),
value: initial,
equals,
};
const get = () => {
track(node);
return node.value;
};
const set = (next: T | ((p: T) => T)) => {
const nxtVal = typeof next === 'function' ? (next as (p: T) => T)(node.value) : next;
if (node.equals(node.value, nxtVal)) return;
node.value = nxtVal;
for (const sub of node.subs) {
if (sub.kind === 'effect') SymbolRegistry.get(sub)?.schedule();
}
};
const subscribe = (observer: Node) => {
link(observer, node);
return () => unlink(observer, node);
};
return { get, set, subscribe, peek: () => node.value };
}
Example usage
import { signal } from './signal';
import { createEffect, onCleanup } from './effect';
const a = signal(1);
const b = signal(2);
const stop = createEffect(() => {
console.log('sum =', a.get() + b.get());
onCleanup(() => console.log('cleanup before rerun'));
});
a.set(10); // microtask: cleanup before rerun → sum = 12
b.set(20); // microtask: cleanup before rerun → sum = 30
stop(); // unsubscribe + cleanup
run() flow diagram
Conclusion
With the flow above, we’ve implemented a functional Effect core. There are still a few things worth calling out:
-
Snapshot trap:
const v = a.get(); a.set(10);vis still the old value. If you need the latest value, callget()again. -
Batched multiple sets: multiple
set()calls in the same tick will re-run the effect only once (microtask batching). -
Dependency drift: every re-run unlinks old deps first, then collects new deps via
withObserver, preventing the subscription set from growing indefinitely.
I spent a lot of time reproducing the diagrams in this article to make it easier to understand. Once graphs are involved, there are many subtle details to get right.
In the next article, we’ll discuss internal design choices for Effect (Symbol vs WeakMap), review the fundamentals of WeakMap, and try an alternative implementation via WeakMapRegistry.




Top comments (0)