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 ​