Introduction
This post continues the idea from the end of the previous article: using closures + destructuring assignment to implement a tiny state “storage” mechanism.
Here’s the starting point:
export type Signal<T> = {
get(): T;
set(next: T | ((prev: T) => T)): void;
};
export function signal<T>(initial: T): Signal<T> {
let value = initial;
const get = () => value;
const set = (next: T | ((p: T) => T)) => {
const nxtVal = typeof next === "function" ? (next as (p: T) => T)(value) : next;
const isEqual = Object.is(value, nxtVal);
if (!isEqual) value = nxtVal;
};
return { get, set };
}
We’re still missing one crucial piece: a place to store dependencies—namely, the Observers. That’s the last puzzle piece needed to make a Signal “reactive”.
Tracking vs Observing
Let’s clarify terminology first:
- Tracking / Trackable : the “source” of information — the thing that can be tracked / subscribed to. The most obvious example: a Signal.
- Observing / Observer: the “subscriber” — the thing that reacts to changes. The most obvious example: an Effect.
With this simple split, we can summarize:
Trackable
- A subscribable source (e.g.
signal,computed) - Internally maintains:
subs: Set<Observer>(who is subscribing to me)
Observer
- An observer (e.g.
computed,effect) - Internally maintains:
deps: Set<Trackable>(who I depend on)
From the observer perspective:

This forms a bidirectional graph:
- Sources know their subscribers
- Subscribers know their sources
Since the rest of the series will optimize around these structures, it helps to be comfortable with basic graph concepts.
TypeScript Types and the Core Model
We can simplify the concept into types like this:
export interface Trackable {
subs: Set<Observer>;
}
export interface Observer {
deps: Set<Trackable>;
}
Minimal Dependency Tracking Core
We’ll implement dependency tracking with currentObserver + track.
let currentObserver: Observer | null = null;
export function withObserver<T>(obs: Observer, fn: () => T): T {
const prev = currentObserver;
currentObserver = obs;
try {
return fn();
} finally {
currentObserver = prev;
}
}
export function track(dep: Trackable) {
if (!currentObserver) return;
dep.subs.add(currentObserver);
currentObserver.deps.add(dep);
}
Key idea:
Any read happening inside the tracking window creates an edge: who read whom.
At this stage, we only build edges.
We do not notify anyone yet. Notification / invalidation (dirtying) will be handled in the next article.
Combine It with the Basic Closure-Based signal
Now we merge the tracking mechanism into the original closure-based Signal and get a minimal subscribable implementation.
We’ll introduce a unified Node model for signal / computed / effect:
type Kind = "signal" | "computed" | "effect";
export interface Node {
kind: Kind;
deps: Set<Node>; // who I depend on (used by computed/effect)
subs: Set<Node>; // who depends on me (signal/computed can be subscribed)
}
// Invariant: signal cannot have deps; effect doesn't expose subs
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 tool: while inside an "observer context", reads auto-create edges
// (build graph only, no notification yet)
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;
}
}
function track(dep: Node) {
if (!currentObserver) return; // normal read outside tracking
link(currentObserver, dep); // Observer -> Trackable
}
// Object return is destructuring-friendly.
// Extract Object.is into equals; next article will use it for notification decisions.
type Comparator<T> = (a: T, b: T) => boolean;
const defaultEquals = Object.is;
export function signal<T>(initial: T, equals: Comparator<T> = defaultEquals) {
// Single node + private value
const node: Node & { kind: "signal"; value: T; equals: Comparator<T> } = {
kind: "signal",
deps: new Set(), // always empty (enforced by link())
subs: new Set(),
value: initial,
equals,
};
const get = () => {
track(node);
return node.value;
};
const set = (next: T | ((prev: 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;
// This post only covers subscription graph building.
// No dirtying / notification yet — next article continues from here.
};
// Imperative subscription (for contrast with declarative tracking)
// Returns an unsubscribe function.
const subscribe = (observer: Node) => {
if (observer.kind === "signal") {
throw new Error("A signal cannot subscribe to another node");
}
link(observer, node);
return () => unlink(observer, node);
};
return { get, set, subscribe, peek: () => node.value };
}
Why Provide Both track and subscribe?
Because they serve different purposes:
track()is declarative dependency tracking: inside a tracking block, whatever you read gets subscribed automatically.
This is whatcomputed/effectwill use.subscribe()is imperative subscription: you can manually attach anObserverto asignal.
This resembles traditional event subscription and is useful for interoperability / bridging to other systems.-
peek()is a practical escape hatch:- convenient for tests
- useful when integrating with external frameworks without creating dependencies
Event Subscription vs Dependency Tracking
| Aspect | Event subscription (subscribe(cb)) |
Dependency tracking (track/withObserver) |
|---|---|---|
| Goal | Call callbacks immediately when value changes | Build a dataflow graph for later recomputation/scheduling |
| How edges are created | Manual register/unregister | Automatically tracked during the “read phase” |
| Best for | I/O, logging, bridging third-party systems | Collecting sources for computed/effect and propagating invalidation |
| Lifecycle | Managed by the user | Can be managed by computed/effect lifecycle automatically |
Closing
At this point, we’ve completed “signal + subscription graph building”:
- Inside a tracking block like
withObserver(() => a.get()), reads automatically create dependency edges: Observer → Trackable. - This post only builds the graph and triggers no re-execution.
Next article is straightforward: implement effect so the graph actually “moves”.
Planned steps:
- Create a
kind: "effect"node, and on first run usewithObserverto collect dependencies. - When any dependent
signal.set()happens, notify the corresponding effects and batch re-runs in a microtask. - Add
dispose / onCleanup: before each rerun, remove old dependencies and run cleanup hooks.



Top comments (0)