Skip to content

Middleware are read-only observers that watch the store without changing it. They are the right place for logging, analytics, devtools integration, and persistence.

import { type Middleware } from '@hurum/core'
const analytics = (): Middleware => ({
name: 'analytics',
onEvent: (event, state) => {
trackEvent(event.type, event)
},
onError: (error, context) => {
reportError(error, { intent: context.intent })
},
})
// Register in the store builder chain
const MyStore = Store({ state: { count: 0 } })
.on({ /* ... */ })
.intents(MyIntents)
.executors(MyExecutor)
.middleware(logger(), devtools(), analytics())

A middleware is an object with a name and one or more optional hook methods. You register middleware via .middleware() at the end of the Store builder chain.

Middleware hooks are called after the action has already happened. They observe the result — they cannot prevent, modify, or intercept anything.

interface Middleware {
name: string
onEvent?(event: StoreEvent, state: State): void
onStateChange?(prevState: State, nextState: State): void
onIntentStart?(intent: string, payload: unknown): void
onIntentEnd?(intent: string, payload: unknown): void
onError?(error: Error, context: ErrorContext): void
}
HookCalled whenArguments
onEventAfter an event is emitted and state is updatedThe event object, the new state
onStateChangeAfter state changes (including computed recalculation)Previous state, next state
onIntentStartWhen an intent begins executingIntent name, the payload
onIntentEndWhen an intent finishes (success or failure)Intent name, the payload
onErrorWhen an error occurs in an executorThe error, context with intent info

Middleware hooks are called at specific points in the intent lifecycle:

store.send(intent)
→ onIntentStart
→ executor runs
→ emit(event)
→ Store.on handler runs (state updates)
→ onEvent
→ onStateChange
→ (executor finishes)
→ onIntentEnd

If an executor throws, onError is called instead of onIntentEnd.

You can register multiple middleware. They are called in the order they are registered:

.middleware(
logger(), // called first
devtools(), // called second
analytics(), // called third
)
const logger = (): Middleware => ({
name: 'logger',
onEvent: (event, state) => {
console.log(`[Event] ${event.type}`, event)
},
onStateChange: (prev, next) => {
console.log('[State]', { prev, next })
},
onIntentStart: (intent, payload) => {
console.log(`[Intent Start] ${intent}`, payload)
},
onIntentEnd: (intent, payload) => {
console.log(`[Intent End] ${intent}`, payload)
},
onError: (error, context) => {
console.error(`[Error] in ${context.intent}:`, error)
},
})
const analyticsMiddleware = (tracker: AnalyticsTracker): Middleware => ({
name: 'analytics',
onEvent: (event) => {
// Track specific events
if (event.type.startsWith('Checkout/')) {
tracker.track(event.type, event)
}
},
onError: (error, context) => {
tracker.trackError(error, {
intent: context.intent,
timestamp: Date.now(),
})
},
})
const persist = (storageKey: string): Middleware => ({
name: 'persist',
onStateChange: (_prev, next) => {
localStorage.setItem(storageKey, JSON.stringify(next))
},
})

Since middleware are just objects, you can conditionally include them:

const MyStore = Store({ state: { /* ... */ } })
.on({ /* ... */ })
.middleware(
...[
process.env.NODE_ENV === 'development' && logger(),
process.env.NODE_ENV === 'development' && devtools(),
analytics(),
].filter(Boolean) as Middleware[],
)

Hurum ships with several built-in middleware:

MiddlewarePurpose
logger()Logs events, state changes, intents, and errors to the console
devtools()Integrates with browser devtools for time-travel debugging
persist()Persists state to localStorage or a custom storage backend
undoRedo()Tracks state history for undo/redo support
  • Middleware cannot modify state. If you need to react to an event and trigger new behavior, use relay or emit additional events from an executor. Middleware is strictly observe-only.
  • Keep middleware lightweight. Middleware hooks run synchronously in the state update path. Heavy computation (like serializing large state for persistence) should be deferred with requestIdleCallback or similar.
  • Use name for debugging. The name property helps identify which middleware is which in error messages and devtools.
  • Don’t put business logic in middleware. Logging what happened is fine. Deciding what should happen next belongs in executors and relay handlers.