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:
// 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 pipelinesubscribe(listener): Register a(state, prevState)callbackgetInitialState(): Returns the original state from the creator (frozen snapshot)destroy(): Tear down listeners, invoke middlewareonDestroy, 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):
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
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:
api— the fullMiddlewareAPI<T>for reading state and subscribingnext— call this to pass the update to the next middleware (orrawSet)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
| Hook | Execution Order | Wrapping Direction |
|---|---|---|
onSet | First middleware is outermost (before/after pattern) | Built bottom-up |
onSubscribe | First middleware is outermost wrapper | Wrapped bottom-up |
init | First to last (array order) | Sequential |
onDestroy | First 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:
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:
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:
setStatebecomes a no-op (checked viaisDestroyed)subscribereturns a no-op unsubscribegetStatestill 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
| Scenario | Store | Atom | Proxy |
|---|---|---|---|
| Single state object with actions | Best fit | Awkward | Possible |
| Middleware (persist, devtools) | Built-in | Not supported | Not supported |
| Selector-based reads | Native | Via derived | Via snapshot |
| Multiple independent values | Possible but verbose | Best fit | Possible |
| Mutable update syntax | Via Immer middleware | No | Best fit |
| SSR isolation | Via scope | Via AtomScope | Via scope |
| Redux DevTools | Native (devtools middleware) | Manual | Manual |
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Object allocation | One new object per setState call (via Object.assign) | O(k) where k = number of state keys |
| Listener iteration | Set iteration — no array copy per notification | O(n) where n = listener count |
| Middleware overhead | Closure chain — one function call per middleware per setState | O(m) where m = middleware count |
| Destroyed guard | Single boolean check at entry points | O(1) |
| Signal integration | Single signal per store — minimal reactive graph overhead | 1 signal node in graph |
| Selector memoization | Framework adapters cache (state, selection) pairs | O(1) cache hit |
Cross-References
- Architecture Overview — where store fits in the layer structure
- Core Design — signal and batch internals that store depends on
- Middleware Overview — pipeline composition, persist, devtools
- Adapters Overview — how
useStorehooks bridge to frameworks - API Reference:
@stateloom/store— consumer-facing documentation