Tools
Tools: Building a Minimal Signal API
2026-01-28
0 views
admin
Introduction ## Tracking vs Observing ## Trackable ## Observer ## TypeScript Types and the Core Model ## Minimal Dependency Tracking Core ## Combine It with the Basic Closure-Based signal ## Why Provide Both track and subscribe? ## Event Subscription vs Dependency Tracking ## Closing 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: Remember this diagram? 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”. Let’s clarify terminology first: With this simple split, we can summarize: From the source perspective: From the observer perspective: This forms a bidirectional graph: Since the rest of the series will optimize around these structures, it helps to be comfortable with basic graph concepts. We can simplify the concept into types like this: We’ll implement dependency tracking with currentObserver + track. 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. 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: Because they serve different purposes: track() is declarative dependency tracking: inside a tracking block, whatever you read gets subscribed automatically.
This is what computed / effect will use. subscribe() is imperative subscription: you can manually attach an Observer to a signal.
This resembles traditional event subscription and is useful for interoperability / bridging to other systems. peek() is a practical escape hatch: At this point, we’ve completed “signal + subscription graph building”: Next article is straightforward: implement effect so the graph actually “moves”. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:
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 };
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
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 };
} COMMAND_BLOCK:
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 };
} CODE_BLOCK:
export interface Trackable { subs: Set<Observer>;
} export interface Observer { deps: Set<Trackable>;
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
export interface Trackable { subs: Set<Observer>;
} export interface Observer { deps: Set<Trackable>;
} CODE_BLOCK:
export interface Trackable { subs: Set<Observer>;
} export interface Observer { deps: Set<Trackable>;
} COMMAND_BLOCK:
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);
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
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);
} COMMAND_BLOCK:
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);
} COMMAND_BLOCK:
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 };
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
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 };
} COMMAND_BLOCK:
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 };
} - 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. - A subscribable source (e.g. signal, computed)
- Internally maintains: subs: Set<Observer> (who is subscribing to me) - An observer (e.g. computed, effect)
- Internally maintains: deps: Set<Trackable> (who I depend on) - Sources know their subscribers
- Subscribers know their sources - track() is declarative dependency tracking: inside a tracking block, whatever you read gets subscribed automatically.
This is what computed / effect will use.
- subscribe() is imperative subscription: you can manually attach an Observer to a signal.
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
- convenient for tests
- useful when integrating with external frameworks without creating dependencies - convenient for tests
- useful when integrating with external frameworks without creating dependencies - 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. - Create a kind: "effect" node, and on first run use withObserver to 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.
how-totutorialguidedev.toaiservernode