Skip to content

Store Design

Low-level design for @stateloom/store — the Zustand-like store paradigm adapter. Covers the store architecture, setState flow, middleware pipeline composition, bootstrap sequence, and subscription model.

Overview

The store paradigm wraps a core signal internally, providing a familiar single-object state management API. Developers define state and actions in a creator function, and the store manages reactivity, middleware composition, and lifecycle. The store implements Subscribable<T> via the underlying signal, so it integrates directly with framework adapters.

Store Architecture

A store is a collection of closures over shared mutable state. The createStore function returns a StoreApi<T> object whose methods (getState, setState, subscribe, destroy) are all closures that close over the same variables:

typescript
// Closed-over mutable state
let state: T;
let isDestroyed: boolean;
let signalRef: Signal<T> | undefined;
let setFn: SetFn<T>;
const listeners: Set<Listener<T>>;

The StoreApi<T> extends Subscribable<T>:

  • get() / getState(): Read the current state via the internal signal (triggers dependency tracking)
  • setState(partial): Update state via the middleware pipeline
  • subscribe(listener): Register a (state, prevState) callback
  • getInitialState(): Returns the original state from the creator (frozen snapshot)
  • destroy(): Tear down listeners, invoke middleware onDestroy, disconnect signal

setState Flow

The setState function accepts either a partial object or an updater function, resolves it, and routes it through the active set pipeline:

Shallow Merge Semantics

State updates use Object.assign({}, currentState, partial):

typescript
const rawSet: SetFn<T> = (partial) => {
  state = Object.assign({}, state, partial) as T;
  stateSignal.set(state);
};

This always creates a new object reference, ensuring that:

  • Subscribers always see a new reference (useful for React's useSyncExternalStore)
  • Equality checks on the state object itself always detect changes
  • Unchanged properties retain their original references (shallow structural sharing)

Middleware Pipeline

Middleware hooks form a chain around setState. The pipeline is built bottom-up so that the first middleware in the array is the outermost wrapper:

Chain Construction

typescript
function buildSetChain<T>(
  rawSet: SetFn<T>,
  middlewares: readonly Middleware<T>[],
  api: StoreApi<T>,
): SetFn<T> {
  let chain: SetFn<T> = rawSet;
  // Build from last to first (bottom-up)
  for (let i = middlewares.length - 1; i >= 0; i--) {
    const mw = middlewares[i];
    if (mw?.onSet !== undefined) {
      const nextSet = chain;
      const onSet = mw.onSet;
      chain = (partial) => {
        onSet(api, nextSet, partial);
      };
    }
  }
  return chain;
}

Each onSet receives three arguments:

  1. api — the full MiddlewareAPI<T> for reading state and subscribing
  2. next — call this to pass the update to the next middleware (or rawSet)
  3. partial — the resolved partial state

Important: A middleware must call next(partial) to propagate the update. Omitting the call blocks the update entirely (useful for filtering, validation, or debouncing).

Hook Execution Order

HookExecution OrderWrapping Direction
onSetFirst middleware is outermost (before/after pattern)Built bottom-up
onSubscribeFirst middleware is outermost wrapperWrapped bottom-up
initFirst to last (array order)Sequential
onDestroyFirst to last (array order)Sequential

Bootstrap Sequence

Store creation follows a carefully ordered sequence to handle circular references between the creator function, middleware, and signal:

Why two phases for setFn? During creator execution (step 1), the signal doesn't exist yet. If the creator calls set() (common for initializing computed state), those calls use a pre-signal setFn that does Object.assign only. After the signal is created (step 3-5), setFn is replaced with the full middleware chain that also updates the signal.

Subscription Model

Store subscriptions provide (state, prevState) tracking via a forwarding listener on the internal signal:

typescript
let prevState = state;
const signalUnsub = stateSignal.subscribe((newState) => {
  if (isDestroyed) return;
  const prev = prevState;
  prevState = newState;
  for (const listener of listeners) {
    listener(newState, prev);
  }
});

The subscribe method wraps listeners through the middleware onSubscribe chain (bottom-up) before adding them to the listener set:

typescript
const subscribe = (listener: Listener<T>): (() => void) => {
  if (isDestroyed) return noop;
  let wrappedListener = listener;
  for (let i = middlewares.length - 1; i >= 0; i--) {
    if (mw.onSubscribe) {
      wrappedListener = mw.onSubscribe(api, wrappedListener);
    }
  }
  listeners.add(wrappedListener);
  return () => {
    listeners.delete(wrappedListener);
  };
};

This allows middleware to transform, filter, or decorate notifications (e.g., a devtools middleware logging every notification).

Destroy Lifecycle

After destruction:

  • setState becomes a no-op (checked via isDestroyed)
  • subscribe returns a no-op unsubscribe
  • getState still works (returns the last state via the signal)

Design Decisions

Why a Signal Backs the Store

Using a core signal means the store automatically participates in the reactive graph. Store reads inside computed() or effect() are tracked as dependencies, enabling derived state that spans paradigms (e.g., a computed that reads from both a store and a raw signal).

Why Object.assign Instead of Spread

Object.assign({}, state, partial) is functionally identical to { ...state, ...partial } but is marginally faster in most engines and avoids edge cases with Symbol keys.

Why Middleware Receives Resolved Partials

The public setState resolves updater functions before passing to the middleware chain. This means middleware always receives a Partial<T> object, never a function. This simplifies middleware implementation — middleware doesn't need to handle both cases.

Why Structural Typing for Middleware

Middleware packages (@stateloom/persist, @stateloom/history) declare their own MiddlewareAPI<T> types that structurally match the store's MiddlewareAPI<T>. This avoids a runtime import of @stateloom/store, keeping middleware packages independent of the paradigm layer (they only peer-depend on @stateloom/core).

Store Lifecycle State Diagram

A store transitions through well-defined lifecycle states:

When to Use Store vs Other Paradigms

ScenarioStoreAtomProxy
Single state object with actionsBest fitAwkwardPossible
Middleware (persist, devtools)Built-inNot supportedNot supported
Selector-based readsNativeVia derivedVia snapshot
Multiple independent valuesPossible but verboseBest fitPossible
Mutable update syntaxVia Immer middlewareNoBest fit
SSR isolationVia scopeVia AtomScopeVia scope
Redux DevToolsNative (devtools middleware)ManualManual

Performance Considerations

ConcernStrategyCost
Object allocationOne new object per setState call (via Object.assign)O(k) where k = number of state keys
Listener iterationSet iteration — no array copy per notificationO(n) where n = listener count
Middleware overheadClosure chain — one function call per middleware per setStateO(m) where m = middleware count
Destroyed guardSingle boolean check at entry pointsO(1)
Signal integrationSingle signal per store — minimal reactive graph overhead1 signal node in graph
Selector memoizationFramework adapters cache (state, selection) pairsO(1) cache hit

Cross-References