DEV Community

Cover image for Building a Minimal Signal API
Luciano0322
Luciano0322

Posted on

Building a Minimal Signal API

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 };
}
Enter fullscreen mode Exit fullscreen mode

Remember this diagram?
signal structure

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)

Diagram:
Role structure

From the source perspective:
source view

From the observer perspective:
observer view

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>;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 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

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:

  1. Create a kind: "effect" node, and on first run use withObserver to collect dependencies.
  2. When any dependent signal.set() happens, notify the corresponding effects and batch re-runs in a microtask.
  3. Add dispose / onCleanup: before each rerun, remove old dependencies and run cleanup hooks.

Top comments (0)