Skip to content

Core Design

Low-level design for @stateloom/core — the reactive kernel that every other package in the StateLoom ecosystem builds on.

Overview

The core package implements a push-pull hybrid reactive system with four primitives: signal, computed, effect, and batch, plus Scope for SSR isolation. At its heart is a dependency graph built from doubly-linked Link nodes that connect sources (signals, computed) to consumers (computed, effects). This document covers the internal data structures, algorithms, and scheduling strategy.

Dependency Graph Data Structure

The graph is built from three node types connected by Link objects. Each Link participates in two doubly-linked lists simultaneously — the source's subscriber list and the consumer's dependency list — enabling O(1) add/remove without Set allocation overhead.

typescript
interface Link {
  source: SourceNode;
  consumer: ConsumerNode;
  prevSub: Link | undefined; // source's subscriber list
  nextSub: Link | undefined;
  prevDep: Link | undefined; // consumer's dependency list
  nextDep: Link | undefined;
  version: number; // source version when last validated
}

SourceNode and ConsumerNode

typescript
interface SourceNode {
  firstSub: Link | undefined;
  lastSub: Link | undefined;
  version: number; // bumped on each value change
}

interface ConsumerNode {
  firstDep: Link | undefined;
  lastDep: Link | undefined;
  flags: number; // dirty state + scheduling flags
  notify(): void;
}

A computed is both a SourceNode (downstream nodes depend on it) and a ConsumerNode (it tracks upstream dependencies). A signal is only a SourceNode. An effect is only a ConsumerNode.

Dirty State Machine

Each consumer carries a flags field that encodes its current state as a bitmask. The dirty state determines whether the consumer needs to recompute or re-execute.

Flag constants:

FlagValueMeaning
CLEAN0Up-to-date, no recomputation needed
MAYBE_DIRTY1Upstream computed notified — needs version check
DIRTY2Upstream signal changed — must recompute
NOTIFIED4Effect is queued for execution (scheduling dedup)
DISPOSED8Permanently stopped, should never run again

Flags are combined with bitwise operations. For example, an effect that is dirty and queued has flags = DIRTY | NOTIFIED = 6.

Push-Pull Hybrid Algorithm

The core reactivity algorithm has two distinct phases:

Push Phase (Eager Dirty Marking)

When a signal value changes, dirty marks propagate eagerly through the subscriber list. This happens synchronously inside signal.set():

  1. Signal increments its version
  2. propagateChange(source) walks the subscriber list
  3. Each subscriber transitions: CLEAN -> DIRTY (with notify() call), or MAYBE_DIRTY -> DIRTY (silent upgrade)
  4. Computed nodes that receive a notification call propagateMaybeDirty() on their own subscriber list — this marks downstream consumers as MAYBE_DIRTY (not confirmed dirty yet)

Pull Phase (Lazy Recomputation)

Computed values recompute only when read. When a MAYBE_DIRTY computed is accessed via .get(), it walks its dependency list checking whether any source's version has actually changed:

typescript
// Simplified refresh logic in ComputedImpl
#refresh(): void {
  if (flags & MAYBE_DIRTY) {
    if (!checkSourcesChanged(consumer)) {
      flags = CLEAN;
      return; // no recomputation needed
    }
  }
  // Recompute
  startTracking(consumer);
  const newValue = fn();
  endTracking(consumer);
  if (newValue !== cachedValue) {
    version++;
    propagateChange(source);
  }
}

checkSourcesChanged() is the key pull operation. For each dependency link, it calls refresh() on any computed sources (ensuring they are up-to-date first), then compares link.version against source.version. Any mismatch means the consumer must recompute.

Diamond Problem Resolution

Consider: signal A feeds computed B and computed C, both of which feed computed D.

  1. Push: A.set() marks B DIRTY and C DIRTY
  2. Push: B.notify() marks D as MAYBE_DIRTY; C.notify() sees D already has a dirty flag, no re-notification
  3. Pull: When D.get() is called, checkSourcesChanged() refreshes B first (which recomputes), then refreshes C (which recomputes), then D recomputes with both updated values

This guarantees D never sees an intermediate state where only B or only C has updated.

During re-evaluation of a consumer, the dependency list from the previous evaluation is available for reuse. This avoids creating new Link objects (and the associated GC pressure) when the dependency set hasn't changed.

The algorithm uses a cursor-based scan:

  1. startTracking(consumer) saves the old dependency chain and sets a cursor at its head
  2. Each track(source) call during evaluation:
    • Fast path: cursor matches this source — splice from old chain, append to new chain (O(1))
    • Slow path: linear search through old chain for matching source
    • Miss: create new Link, subscribe to source
  3. endTracking(consumer) removes all remaining old links (stale dependencies) from their source subscriber lists

The fast path handles the common case where dependencies are read in the same order across evaluations. The tracking context supports nesting via a trackingStack — computed nodes read during another computed's evaluation save and restore the parent's tracking state.

Signal Implementation

SignalImpl wraps a value with a SourceNode and a subscriber Set:

  • get(): Calls track(source) for dependency tracking, returns value
  • set(value): Checks equality via Object.is (or custom equals). If changed: stores value, bumps source.version, wraps propagation in startBatch/endBatch, calls propagateChange(source), schedules subscriber notification
  • subscribe(callback): Adds callback to a Set. Returns an unsubscribe function
  • update(fn): Shorthand for set(fn(get()))

Each set() wraps its propagation in an internal batch. Outside an explicit batch(), this means effects and subscriber callbacks run synchronously at the end of set(). Inside an explicit batch(), the internal batch nests and flushing defers to the outermost batch.

Subscriber notifications use a #notificationQueued flag to deduplicate — multiple set() calls in a batch produce only one notification callback.

Computed Implementation

ComputedImpl acts as both source and consumer. Key internal fields:

  • #source: SourceNode & { refresh() } — downstream nodes depend on this
  • #consumer: ConsumerNode — tracks upstream dependencies
  • #fn: () => T — derivation function
  • #value: T — cached result
  • #hasValue: boolean — whether computed at least once

Creation: Consumer starts with flags = DIRTY, so the first get() triggers computation.

get(): Calls track(source) for downstream tracking, then #refresh():

  1. CLEAN: return cached value (no work)
  2. MAYBE_DIRTY: call checkSourcesChanged() — if unchanged, mark CLEAN and return cached
  3. DIRTY: startTracking -> fn() -> endTracking. If new value differs from cached, bump source.version, propagateChange(), notify subscribers

notify() (called by upstream change): calls propagateMaybeDirty(source). This is the key difference from signals — a computed's change is uncertain until recomputation, so downstream gets MAYBE_DIRTY, not DIRTY.

Error handling: If fn() throws, endTracking runs in the catch path so tracking state is properly restored. The error propagates to the caller of get().

Effect Implementation

EffectImpl implements RunnableConsumer — a ConsumerNode with a run() method the scheduler can call.

  • Creation: Starts with flags = DIRTY, runs synchronously in the constructor
  • notify(): Calls scheduleEffect(this) to queue for async execution
  • run(): Checks flags — skips if DISPOSED or CLEAN. For MAYBE_DIRTY, checks sources first. Runs cleanup from previous execution, then startTracking -> fn() -> endTracking. If fn returns a function, stores it as cleanup
  • dispose(): Sets DISPOSED flag, runs final cleanup, calls removeAllDeps() to disconnect from all sources

Effects support cleanup functions: if the effect body returns a function, that function is called before each re-execution and on disposal. This enables resource management (abort controllers, event listeners, timers).

Batch Implementation

Batching coalesces multiple signal writes into a single notification cycle:

Key behaviors:

  • Nested batches: Only the outermost endBatch() triggers flushing (depth must reach 0)
  • Flush order: Subscriber notifications first, then effects
  • Values are immediate: signal.get() inside a batch returns the new value before flushing
  • Error safety: batch() uses try/finally so flushing always happens
  • Re-entrant: Effects that schedule more effects during flush are processed in subsequent rounds (uses splice(0) pattern to drain the queue)

The effect scheduler uses queueMicrotask when outside a batch. The NOTIFIED flag prevents duplicate scheduling — an effect already in the queue is not added again. The DISPOSED flag causes queued effects to be skipped during flush.

Scope Implementation

Scopes provide per-request state isolation for SSR environments.

ScopeImpl uses a Map<Subscribable<unknown>, unknown> keyed by subscribable identity, with an optional parent scope for inheritance:

  • fork(): Creates new ScopeImpl(this) — child inherits parent values, can override independently
  • get(subscribable): Checks local Map, then parent chain, then global .get()
  • set(signal, value): Stores in local Map only (does not propagate to parent)
  • serialize(): Iterates the Map, mapping each subscribable to a stable auto-incrementing key via a module-level WeakMap

runInScope(scope, fn): Sets a module-level currentScope, runs fn, restores previous scope in finally. Supports nesting — inner scopes take precedence.

Design Decisions

Why Doubly-Linked Lists Instead of Sets

Set<Link> would work but creates per-source overhead. The doubly-linked list gives O(1) add/remove with zero allocation (the Link object itself serves as the list node). This matters for fine-grained reactivity where thousands of signals may exist.

Why MAYBE_DIRTY Exists

Without MAYBE_DIRTY, every upstream change would force recomputation of all downstream computed nodes, even when the intermediate computed's value doesn't actually change (e.g., computed(() => items.get().length) when items are swapped but length stays the same). MAYBE_DIRTY enables the "check before recompute" optimization.

Why Each set() Has an Internal Batch

Wrapping every set() in startBatch/endBatch ensures that subscriber notifications and effects always run after the full propagation of a single write. Without this, subscriber callbacks could fire in the middle of propagation, seeing partially-updated state.

Why Effects Run Synchronously (Not via Microtask)

Each signal.set() wraps propagation in an internal batch, and endBatch() flushes effects synchronously. This means effects re-execute at the end of the triggering set() call. The queueMicrotask path only applies when effects are scheduled outside a batch (which is rare in practice). This design ensures predictable ordering and avoids the "stale read" problem where code after set() runs before effects.

Scope Isolation Model

Scopes provide isolated state universes for SSR. Each server request creates a scope so state is never shared between requests:

Performance Considerations

ConcernStrategyComplexity
Memory allocationLink recycling reuses nodes across re-evaluationsO(1) per reused link
GC pressureDoubly-linked lists avoid Map/Set overhead per dependencyO(1) add/remove
Glitch preventionPush-pull hybrid ensures consistent state without topological sortingNo O(n log n) sort
Notification dedupNOTIFIED flag prevents duplicate effect scheduling; #notificationQueued on signals prevents duplicate subscriber callbacksO(1) flag check
Batch overheadSimple counter — zero allocation for nestingO(1) per nest level
Scope overheadMap.has() per read (fast path); lazy allocation (only stores overridden values)O(k) where k = scope depth
Bundle size~1.5 KB gzipped; zero platform APIs; uses only queueMicrotask, Object.is, Set, WeakMap

Memory Patterns

PrimitivePer-Instance AllocationWhen GC-Eligible
signal1 object + 1 SourceNode + 1 SetWhen no external references remain
computed1 object + 1 SourceNode + 1 ConsumerNode + 1 SetWhen no external references and no subscribers
effect1 object (ConsumerNode) + Links per dependencyAfter dispose() and removeAllDeps()
Link1 object per dependency edgeWhen consumer is re-evaluated or disposed
Scope1 object + 1 Map (entries only for overrides)When scope reference is dropped

Cross-References