Skip to content

This page describes how Hurum is built internally. You don’t need this information to use Hurum, but it helps when debugging, contributing, or building integrations.

Hurum ships as two packages with a strict dependency direction:

@hurum/react → @hurum/core
(peer dep) (zero deps)

Framework-agnostic state machine runtime. Zero runtime dependencies. Dual CJS + ESM build.

ModuleResponsibility
events.tsEvents() / Event() factories, event type branding
command-executor.tsCommandExecutor() factory, passthrough shorthand, executor context
intent.tsIntent() / Intents() / Intent.all() / Intent.allSettled(), PreparedIntent
store.tsStore() builder, createStoreInstance(), nested management, relay, computed, subscription
computed.tsProxy-based dependency tracking, structural equality
nested.tsNested() / Nested.array() / Nested.map() markers
selector.tsSelector type and isSelector guard
middleware.tsMiddleware / MiddlewareFactory types
middleware/Built-in middleware: logger, persist, devtools, undoRedo
types.tsUtility types: StoreOf, StateOf, RawStateOf, DepsOf, DetectConflicts

Thin React binding layer. Peer depends on React 18+ and @hurum/core.

ModuleResponsibility
use-store.tsuseStore() hook — context-aware: resolves scoped instance from StoreProvider or falls back to singleton
use-selector.tsuseSelector()useSyncExternalStore with custom selector
hooks.tsInternal use.* field hooks via Proxy
provider.tsxStoreProvider component + React context
with-provider.tsxwithProvider(def, Component) HOC
singleton.tsSingleton instance management for fallback

Store() returns a builder. Each chained method (.on(), .intents(), .computed(), .middleware(), .nested(), .deps()) appends configuration to an internal BuilderConfig. The builder is immutable — each call returns a new builder instance.

const def = Store({ state: { count: 0 } })
.on(CounterEvent, { incremented: (s, p) => ({ ...s, count: s.count + p.amount }) })
.intents(CounterIntents)
.computed({ doubled: (s) => s.count * 2 })

The final .intents() or .computed() call produces a StoreDefinition — a frozen configuration object that can create instances.

def.create(options?) calls createStoreInstance(), which:

  1. Merges initial stateoptions.initialState is deep-merged with the default state from the definition.
  2. Initializes nested stores — For each Nested, Nested.array, or Nested.map marker in state, creates child StoreInstance(s) using the child StoreDefinition.
  3. Resolves nested defaults — Nested single slots are populated with the child store’s initial state (not left as null).
  4. Sets up nested sync — Subscribes to child stores so that child state changes update the parent’s combined state.
  5. Builds executor index — Maps each Command to its Executor for fast lookup during dispatch.
  6. Initializes middleware — Calls each MiddlewareFactory.create() with the store instance.
  7. Computes initial computed values — Runs all computed functions against the initial state.

When store.send(intent) is called:

send(intent)
├─ middleware.onIntentStart(intent)
├─ resolve intent → Command[]
├─ for each Command (sequential by default):
│ ├─ find Executor for Command
│ ├─ executor(command, { deps, emit, getState, signal, scope })
│ │ ├─ emit(event) ← synchronous
│ │ │ ├─ middleware.onEvent(event)
│ │ │ ├─ store.on[event.type](state, payload) → newState
│ │ │ ├─ middleware.onStateChange(prev, next)
│ │ │ ├─ processRelay(event, state)
│ │ │ │ └─ forwardEventToNestedChildren(relayedEvents)
│ │ │ ├─ recompute affected computed values
│ │ │ └─ notify subscribers
│ │ └─ (next emit...)
│ └─ if executor throws → middleware.onError(error)
└─ middleware.onIntentEnd(intent)

Key invariant: emit() is synchronous. State is updated, computed values recalculated, and subscribers notified before emit() returns.

store.dispose():

  1. Disposes all nested child stores recursively.
  2. Clears all subscriptions.
  3. Marks the instance as disposed.
  4. Subsequent send() calls throw. Subsequent emit() calls are silently ignored.

Each nested type has a corresponding internal manager:

Nested typeManagerStorage
Nested(ChildStore)NestedSingleManagerSingle StoreInstance
Nested.array(ChildStore)NestedArrayManagerMap<string, StoreInstance> keyed by item ID
Nested.map(ChildStore)NestedMapManagerMap<string, StoreInstance> keyed by map key

Parent state includes the combined state of all children. When a child’s state changes:

  1. Child notifies parent via subscription.
  2. Parent calls recomputeFromNestedChange().
  3. Parent’s combined state is rebuilt.
  4. Parent’s computed values recalculate.
  5. Parent’s subscribers are notified.

Events flow in two directions:

Downward (relay): Parent relay handlers can produce events that are forwarded to child stores via forwardEventToNestedChildren(). Relay has a depth limit (MAX_RELAY_DEPTH = 5) to prevent infinite cycles.

Upward (bubbling): When a child store processes an event, the parent is notified. If the parent has a relay handler for that event type, it can respond.

When raw state changes (e.g., a new item appears in an array), syncNestedArray() / syncNestedMap() diffs the current instances against the new state:

  • New keys → create new child instances
  • Removed keys → dispose removed child instances
  • Existing keys → leave child instances intact (their state is managed by their own event handlers)

Computed values use Proxy-based dependency tracking:

  1. On first run, a Proxy wraps the state object.
  2. The computed function runs against the proxy.
  3. Every property access on the proxy is recorded as a dependency.
  4. On subsequent state changes, only computed functions whose dependencies changed are recalculated.

Structural equality: After recalculation, the new value is compared to the previous value using structuralEqual(). If they are equal, the previous reference is kept, preventing unnecessary subscriber notifications.

Middleware receives lifecycle hooks:

type Middleware = {
onIntentStart?(intent): void
onIntentEnd?(intent): void
onEvent?(event): void
onStateChange?(prev, next): void
onError?(error): void
}

A MiddlewareFactory has a name and a create(store) method that returns a Middleware. Built-in factories:

FactoryPurpose
logger()Logs events, state changes, and errors to console
persist()Persists and rehydrates state (localStorage, custom storage)
devtools()Connects to browser DevTools extensions
undoRedo()Tracks state history for undo/redo

useStore(def) is the primary hook for accessing stores in React components:

  1. Checks if a StoreProvider for the given definition exists in the component tree via React context.
  2. If found, returns a handle wrapping the scoped instance.
  3. If not found, falls back to the global singleton (created lazily on first access).

The returned UseStoreReturn object provides:

  • use.* — A Proxy that creates per-field hooks. store.use.count() returns a hook that subscribes to state.count via useSyncExternalStore.
  • send — The store instance’s send proxy, retaining intent name shortcuts.
  • useSelector(fn) — Custom selector hook using useSyncExternalStore.
  • getState, subscribe, cancel, cancelAll, dispose, scope — Direct access to the underlying store instance methods.

useStore(instance) is an overload that wraps a given StoreInstance directly, bypassing context resolution.

StoreProvider accepts a store definition via the of prop and optionally an existing store instance. If no instance is provided, it auto-creates one using the optional initialState and deps props. It provides the instance to its children via React context.

  • Singleton (fallback when no StoreProvider is present): A module-level instance created on first access. Client-side only.
  • Scoped (<StoreProvider> + useStore()): Instance-per-provider. Required for SSR, testing, and multi-instance scenarios.
ExtensionMechanism
Custom side effectsWrite a CommandExecutor
Cross-cutting concernsWrite a MiddlewareFactory
Framework bindingsSubscribe to StoreInstance via store.subscribe()
Custom derived stateAdd computed functions in .computed()
Inter-store coordinationUse relay and scope in executors

Hurum intentionally has no plugin system or hook registry. Every extension is a first-class concept in the architecture.