Skip to content

Architecture ​

Design principles ​

  1. State-first — use plain state + actions for 90% of use cases
  2. Streams optional — add RxJS-compatible streams only at boundaries
  3. Core-first — @ngstato/core is framework-agnostic; adapters stay thin
  4. No magic — Proxy-based mutations, explicit lifecycle, zero decorators

Package layers ​

┌─────────────────────────────────────────────────┐
│                  Your App                       │
├─────────────────────────────────────────────────┤
│            @ngstato/angular                     │
│  ┌─────────────┬───────────┬──────────────┐     │
│  │ StatoStore() │ injectStore│ DevTools     │     │
│  │ (DI factory)│ (inject)  │ (component)  │     │
│  └─────────────┴───────────┴──────────────┘     │
│         Signals ← subscribe() → Core           │
├─────────────────────────────────────────────────┤
│            @ngstato/core                        │
│  ┌──────────┬──────────┬──────────┬──────────┐  │
│  │  Store   │ Helpers  │ HTTP     │ DevTools │  │
│  │ engine   │ 18+      │ client   │ runtime  │  │
│  └──────────┴──────────┴──────────┴──────────┘  │
├─────────────────────────────────────────────────┤
│            @ngstato/testing                     │
│  ┌──────────────────────────────────────────┐   │
│  │       createMockStore()                  │   │
│  └──────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘

How the store engine works ​

1. createStore(config) boot sequence ​

createStore(config)
  │
  ├── 1. Extract state (everything except actions/computed/selectors/hooks/effects)
  ├── 2. Register actions
  ├── 3. Register computed (live recalculation)
  ├── 4. Register selectors (memoized with Proxy-based dep tracking)
  ├── 5. Register effects
  │
  ├── 6. Build public store object
  │     ├── State properties → Object.defineProperty (getter → getState()[key])
  │     ├── Actions → wrapper functions (hide `state` param, call dispatch())
  │     ├── Computed → Object.defineProperty (getter → getComputed())
  │     └── Selectors → Object.defineProperty (getter → getSelector())
  │
  └── 7. init(publicStore)    ← unless skipInit: true
        ├── Call onInit hook
        └── Run effects (force = true)

2. Action dispatch flow ​

When you call store.loadUsers():

store.loadUsers(arg1, arg2)
  │
  ├── 1. onAction hook fires ('loadUsers', [arg1, arg2])
  ├── 2. Capture prevState = { ...state }
  ├── 3. Create state Proxy
  │     └── set trap: state.x = y → _setState({ x: y }) → notify + effects
  ├── 4. Execute action(stateProxy, arg1, arg2)
  │     └── Every mutation triggers subscribers immediately
  ├── 5. On success:
  │     ├── onActionDone('loadUsers', duration)
  │     ├── onStateChange(prev, next) — only if state actually changed
  │     └── Emit action event to action bus (for on() reactions)
  └── 6. On error:
        ├── onError(error, 'loadUsers')
        ├── Emit error event to action bus
        └── Re-throw the error

3. Memoized selectors — Proxy-based dependency tracking ​

selector: sortedUsers(state) => [...state.users].sort(...)

First read:
  1. Create tracking Proxy around state
  2. Execute function → proxy records: reads = ['users']
  3. Cache: trackedKeys = ['users'], cachedResult = [sorted array]

Subsequent reads:
  1. Check: Object.is(state.users, trackedValues[0]) ?
  2. Same reference → return cachedResult (no re-sort)
  3. Different reference → re-execute, re-track, re-cache

4. Effects — dependency-driven side effects ​

effects: [
  [(state) => state.selectedId, (id, { store }) => { ... }]
]

On every _setState():
  1. Evaluate deps: state.selectedId → current value
  2. Compare with previous value via Object.is
  3. Changed? → run effect(newValue, { state, store, prevDepsValue })
  4. Effect returns cleanup? → store it, call before next run

Angular adapter — thin layer ​

The Angular adapter does 3 things:

  1. Creates Angular Signals for each state property
  2. Subscribes to core store changes → updates Signals
  3. Wraps computed/selectors as Angular computed() Signals

Key implementation detail — the skipInit pattern:

ts
// Angular adapter uses skipInit to control init timing
const coreStore = createStore(config, { skipInit: true })

// ... setup Signals, subscribe, build angularStore ...

// Init ONCE with the fully-built Angular store
coreStore.__store__.init(angularStore)

This ensures:

  • Effects see the Angular store (with Signals) from their very first run
  • onInit receives the Angular store, not the raw core store
  • No double-init, no duplicate effect execution

Action bus — inter-store communication ​

Store A                     Store B
  │                           │
  ├── dispatch('save')        │
  │     └── emitActionEvent   │
  │           └─── action bus ──── on(storeA.save, handler)
  │                           │     └── handler(storeB, event)
  │                           │

on() uses a WeakMap-based subscriber system keyed by action function references. This means:

  • No string-based coupling between stores
  • Type-safe action references
  • Automatic cleanup when actions are garbage-collected

File map ​

Core engine ​

FilePurpose
store.tscreateStore(), StatoStore class, on()
types.tsAll TypeScript interfaces and types
http.tsStatoHttp client
action-bus.tsInter-store event system
devtools.tsDevTools runtime

Helpers (18 files) ​

FilePurpose
abortable.tsCancel previous execution
debounced.tsDebounce action calls
throttled.tsThrottle action calls
retryable.tsRetry with backoff
exclusive.tsIgnore new calls while running
queued.tsExecute in order
optimistic.tsOptimistic update + rollback
distinct-until-changed.tsSkip duplicate calls
fork-join.tsParallel execution (all)
race.tsParallel execution (first wins)
combine-latest.tsCombine state dependencies
combine-latest-stream.tsCombine external streams
from-stream.tsSubscribe to Observable-like
stream-operators.ts12 pipe operators
entity-adapter.tsNormalized collections
with-entities.tsEntity config wrapper
with-feature.tsmergeFeatures() composition
with-persist.tsState persistence

Angular adapter ​

FilePurpose
create-angular-store.tsCore → Signals bridge
inject-store.tsinjectStore() helper
provide-ngstato.tsprovideStato() provider
devtools.component.ts<stato-devtools> component

Released under the MIT License.