Tools: Building a Minimal Signal API

Tools: Building a Minimal Signal API

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 ? It will become hidden in your post, but will still be visible via the comment's permalink. as well , this person and/or 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 }; } 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>; } 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); } 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 }; } 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.