Skip to content

The Store is the central unit in Hurum. It holds everything for one process: state, event handlers, computed values, executors, dependencies, relay rules, and middleware.

import { Store } from '@hurum/core'
const PurchaseStore = Store({
state: {
purchase: null as Purchase | null,
saving: false,
error: null as SaveError | null,
},
})
.on(PurchaseEvent, {
saveRequested: (state) => ({
...state,
saving: true,
error: null,
}),
saved: (state, { purchase }) => ({
...state,
purchase,
saving: false,
}),
saveFailed: (state, { error }) => ({
...state,
saving: false,
error,
}),
})
.computed({
canSubmit: (state) => state.purchase !== null && !state.saving,
totalAmount: (state) =>
state.purchase?.items.reduce((sum, item) => sum + item.amount, 0) ?? 0,
})
.intents(PurchaseIntents)
.executors(SaveExecutor, LoadExecutor)
.deps({ repository: new PurchaseRepository() })
.middleware(logger())

A Store is built using a fluent builder chain. Each method adds a layer of behavior and returns a new Store definition:

MethodPurpose
Store({ state })Define initial state
.on(Events, { ... })Register event handlers (pure state transitions)
.computed({ ... })Define derived state
.relay(Event, fn)Forward events between parent and nested child stores
.intents(...)Register intent namespaces
.executors(...)Register command executors
.deps({ ... })Provide dependencies for executors
.middleware(...)Attach read-only observers

The .on method registers pure functions that transition state in response to events:

.on(CounterEvent, {
incremented: (state, { amount }) => ({
...state,
count: state.count + amount,
}),
reset: () => ({
count: 0,
}),
})

Each handler receives the current state and the event payload, and returns the next state. Handlers must be pure — no side effects, no async, no mutations.

Once a Store is defined, you interact with it by sending intents:

// v2 Send API -- shorthand
PurchaseStore.send.submitButtonClicked({ id: '123' })
// v2 Send API -- PreparedIntent form
PurchaseStore.send(PurchaseIntents.submitButtonClicked({ id: '123' }))

Both forms are equivalent. The shorthand is more concise for common use; the PreparedIntent form is useful when you need to pass intents around as values.

// Get the full state (raw + computed)
const state = PurchaseStore.getState()
console.log(state.purchase) // raw state
console.log(state.canSubmit) // computed value
// Subscribe to state changes
const unsubscribe = PurchaseStore.subscribe((state) => {
console.log('State changed:', state)
})
// Subscribe to raw events
const unsubEvents = PurchaseStore.subscribe('events', (event) => {
console.log('Event emitted:', event.type)
})

By default, a Store definition acts as a global singleton. This is convenient for client-side apps where one instance per store is the norm:

// Singleton -- same instance everywhere
const CounterStore = Store({ state: { count: 0 } })
.on({ /* ... */ })
.intents(CounterIntents)
.executors(IncrementExecutor)
// Use directly
CounterStore.send.plusClicked({ amount: 1 })
CounterStore.getState().count

For SSR or when you need isolated instances, use Store.create():

// Scoped instance -- independent state
const store = PurchaseStore.create({
initialState: { purchase: existingPurchase },
deps: { repository: mockRepository },
})
store.send.submitButtonClicked({ id: '123' })
store.getState()

Store.create options:

OptionBehavior
initialStateDeep-merged with the Store’s default state
depsShallow-merged with the Store’s default deps

Call store.dispose() to clean up a store instance:

const store = PurchaseStore.create()
// ... use the store ...
store.dispose()

Disposal does the following:

  • Aborts all running executors
  • Unsubscribes all listeners
  • Disposes all nested child stores
  • Marks the store as disposed

After disposal:

  • store.send(...) throws an error
  • emit() inside any still-running executor is silently ignored

If your store has nested stores, access their instances via scope:

const PurchaseStore = Store({
state: {
transaction: Nested(TransactionStore),
items: Nested.array(ItemStore),
},
})
// Access child store instances
PurchaseStore.scope.transaction // TransactionStore instance
PurchaseStore.scope.items // ItemStore[] instances

The simplest possible store:

const ToggleStore = Store({ state: { on: false } })
.on(ToggleEvent, {
toggled: (state) => ({ on: !state.on }),
})
.intents(ToggleIntents)
.executors(ToggleExecutor)
const UserStore = Store({
state: { user: null as User | null, loading: false },
})
.on({ /* ... */ })
.intents(UserIntents)
.executors(LoadUserExecutor, UpdateUserExecutor)
.deps({
api: new UserApi(),
analytics: new AnalyticsService(),
})

You can chain .on multiple times. Handlers are merged:

const MyStore = Store({ state: { /* ... */ } })
.on(EventA, (state, payload) => ({ /* ... */ }))
.on(EventB, (state, payload) => ({ /* ... */ }))
  • One store per process. A “purchase editor,” a “user profile,” a “search panel” — each gets its own store. Don’t put unrelated state in the same store.
  • Keep .on handlers simple. They should be one-liners or near it. Complex logic belongs in executors (before emitting) or computed values (after state changes).
  • Use Store.create() for tests. Even if your production code uses singletons, tests should use scoped instances so they don’t leak state between test cases.
  • Always call dispose() in tests. After each test, dispose the store to cancel any running executors and prevent memory leaks.