@hyperfrontend/ state-machine§
Lightweight, functional state management library with Redux-inspired actions/reducers, async operation orchestration, and lifecycle-aware component abstractions for predictable application state.
What is @hyperfrontend/state-machine?
Most state libraries let you model anything. This one models one thing well: the lifecycle of an async operation. Four booleans go in (inProgress, success, fail, halt) and named states come out, and the interesting ones are the combinations a lone isLoading flag throws away. Running after a failure is retrying, not another first attempt. Running after a success is restarting, which is the state a table is in while it refreshes with the old rows still on screen. Never run at all is notStarted, which is usually a different screen from a spinner. Those distinctions come out of the same start, success, and fail dispatches you were going to write anyway.
There is no framework here and no third-party dependency: no provider to mount, no hook, no adapter. Store is a subscribe and dispatch container over one reducer. AsyncOperation wraps a promise-returning function and dispatches start, success, and fail around it so you can listen for events instead of polling flags. LifecycleAwareComponent is a base class for anything with an init step, and it closes the usual startup race: a callback registered after the component is already ready fires immediately rather than waiting for a transition that has been and gone.
Reach for Redux, Zustand, or XState when you want what they bring with them: devtools and time travel, a middleware ecosystem, or real statecharts with guards, hierarchy, and parallel regions. This library has none of that and is not trying to grow it.
At a glance:
import { start, success, fail } from '@hyperfrontend/state-machine/actions'
import { derivedState } from '@hyperfrontend/state-machine/selectors'
import { Store } from '@hyperfrontend/state-machine/store'
const store = new Store()
store.subscribe((state) => render(derivedState(state)))
store.dispatch(start()) // inProgress
store.dispatch(fail(new Error('offline'))) // failed, done
store.dispatch(start()) // retrying: running, and the previous attempt failed
store.dispatch(success()) // successful, done
store.dispatch(start()) // restarting: running, with a good result still on screen
Key Features
Central store with immutable state, action dispatching, and pure reducers
Ready-to-use reducer for START/PAUSE/CANCEL/SUCCESS/FAIL action types
Automatic computation of notStarted, done, paused, cancelled, retrying, restarting from core state
Observer pattern with event handlers triggered when specific derived states activate
AsyncOperationclass that automatically dispatches actions for promise lifecyclesCoordinatedAsyncProcessfor managing multiple async operations with startAll/cancelAll/pauseAllAbstract class with initializing/ready/starting/stopping/active state tracking and callbacks
Modular exports
Tree-shakeable secondary entry points for actions, reducers, selectors, events, and async operations
No third-party runtime dependencies
Only two hyperfrontend utility packages, nothing outside the org
Why Use @hyperfrontend/state-machine?
The states you actually need are the ones a loading flag cannot express
isLoading plus error is two booleans and four combinations, and none of them record what happened last time. It cannot tell a first load from a refresh, so the spinner covers rows you could have kept on screen. It cannot tell a retry from a first attempt, so the error banner vanishes the moment you try again and nobody can see that a second attempt is running. Here those are restarting and retrying, both falling out of the start and fail dispatches you were already making, and notStarted stays distinct from inProgress, so an empty screen and a loading screen are different renders.
Nothing to mount, nothing to adapt
There is no provider, no hook, no framework binding, no third-party package pulled in behind it. store.subscribe returns its own unsubscribe, AsyncOperation is a class you can hold in a closure, and both work the same in a React effect, a Svelte store, a worker, or a plain script. That matters most in the places a framework store cannot reach: a shared module, an SDK, an embed, a Node process.
Init races handled by the base class, not by convention
Anything with a startup step has the same bug waiting in it: something subscribes after initialization finished and never hears about it. LifecycleAwareComponent fires a freshly registered onReadyStatusChange or onActiveStatusChange callback right away if the component is already in that state, and its setters skip the notification entirely when a value has not changed, so subscribers get one call per real transition and never miss the one that already happened.
Coordination that is honest about what it can do
CoordinatedAsyncProcess registers several processes and gives you startAll(), pauseAll(), and cancelAll(). Be clear on what cancel means: it marks the operations halted, it does not abort a promise already in flight. If your work needs a real abort, pass an AbortSignal into the process yourself and use this for the state around it.
Installation
npm install @hyperfrontend/state-machine
Quick Start
Basic Store Usage
import { Store } from '@hyperfrontend/state-machine/store'
import { start, success, fail } from '@hyperfrontend/state-machine/actions'
// Create store
const store = new Store()
// Subscribe to state changes
const unsubscribe = store.subscribe((state, action) => {
console.log('State changed:', state, 'Action:', action.type)
})
// Dispatch actions
store.dispatch(start())
console.log(store.getState())
// { inProgress: true, success: false, fail: false, halt: false }
store.dispatch(success())
console.log(store.getState())
// { inProgress: false, success: true, fail: false, halt: false }
// Cleanup
unsubscribe()
Async Operation with Automatic State Management
import { AsyncOperation } from '@hyperfrontend/state-machine/async-operation'
// AsyncProcess is () => Promise<void>, so keep the result outside it
let user: User | null = null
const fetchUser = async () => {
const response = await fetch('/api/user')
if (!response.ok) throw new Error(`User request failed: ${response.status}`)
user = await response.json()
}
const operation = new AsyncOperation(fetchUser)
// start, success, and fail are dispatched for you; you listen instead of polling flags
operation.on('inProgress', () => showSpinner())
operation.on('successful', () => render(user))
operation.on('failed', () => showError())
// second call after a failure lands in retrying, after a success in restarting
operation.on('retrying', () => keepErrorBannerUp())
operation.on('restarting', () => keepStaleRowsVisible())
await operation.start()
Lifecycle-Aware Component
import { LifecycleAwareComponent } from '@hyperfrontend/state-machine/lifecycle-aware-component'
class DatabaseConnection extends LifecycleAwareComponent {
private connection: any = null
protected init = async () => {
this.setInitializing(true)
this.connection = await createDatabaseConnection()
this.setInitializing(false)
this.setReady(true)
return 'success'
}
public start = async () => {
if (!this.ready) await this.init()
this.setStarting(true)
await this.connection.connect()
this.setStarting(false)
this.setActive(true)
return 'success'
}
public stop = async () => {
this.setStopping(true)
await this.connection.disconnect()
this.setStopping(false)
this.setActive(false)
return 'success'
}
}
// Usage
const db = new DatabaseConnection()
db.onReadyStatusChange((ready) => {
console.log(ready ? 'Database ready' : 'Database not ready')
})
db.onActiveStatusChange((active) => {
console.log(active ? 'Database connected' : 'Database disconnected')
})
await db.start() // Triggers init → ready → starting → active
await db.stop() // Triggers stopping → inactive
Coordinated Async Operations
import { CoordinatedAsyncProcess } from '@hyperfrontend/state-machine/coordinated-async-operation'
const coordinator = new CoordinatedAsyncProcess()
coordinator
.registerProcess(async () => {
await loadImages()
})
.registerProcess(async () => {
await loadStyles()
})
.registerProcess(async () => {
await loadScripts()
})
// Start all processes in parallel
await coordinator.startAll()
// Or cancel all if needed
coordinator.cancelAll()
API Overview
The whole state is four booleans: inProgress, success, fail, and halt. A Store holds one copy of them, five action creators (start, pause, cancel, success, fail) are the only things that move them, and START is the handler that carries history forward: it sets inProgress and leaves whatever success or fail was already there. That is the whole trick, and everything else reads those four flags back.
Reading them back is what the selectors do. Ten of them take a state and return a boolean, and derivedState computes the set in a single call and hands back a named object to render from. notStarted, inProgress, done, successful, failed, halted, paused, and cancelled are the ones you would expect. The two that pay for the package are retrying, true while an attempt runs after a failure, so the error banner can stay up instead of blinking off, and restarting, true while a refresh runs with a good result still on screen, so a table can keep its rows. A lone isLoading is true at both of those moments and at a first load, and says the same thing at all three.
Two classes do that dispatching for you. AsyncOperation takes a () => Promise<void>, dispatches start before it and success or fail after it, and lets you listen by derived-state name: on('retrying', handler) fires on the transition into that state, once per activation. CoordinatedAsyncProcess is that wrapper over a set of processes, with startAll(), pauseAll(), and cancelAll(). LifecycleAwareComponent tracks a different lifecycle: the initializing, ready, starting, stopping, and active of a long-lived object, where a callback registered after that state is already true is called straight away rather than waiting for a transition that has been and gone.
Every folder is its own entry point, so an import can take just the surface you need. Reach for /selectors and /models when something else already owns the state and you only want to read and type it; /store, /actions, /reducer, and /state to drive the flags yourself; /events and /state-change for the edge-triggered dispatcher and the previous-versus-current pair it compares; and /async-operation, /coordinated-async-operation, or /lifecycle-aware-component when you would rather a class did the dispatching. The root entry re-exports all of it, which is the easier start and the one the bundler can still tree-shake.
Every action, selector, event name, and type is in the API reference.
Compatibility
Runs on
- Node.js>=18.0.0supported
- Browserssupported
- Web Workerssupported
Ships as
- ESMTree-shakeable
- CJSNode and older bundlers
- IIFEHyperfrontendStateMachine
- UMDHyperfrontendStateMachine
CDN Usage
<!-- unpkg -->
<script src="https://unpkg.com/@hyperfrontend/state-machine"></script>
<!-- jsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/@hyperfrontend/state-machine"></script>
<script>
const { Store, AsyncOperation } = HyperfrontendStateMachine
const { start, success, fail } = HyperfrontendStateMachine
</script>
Global variable: HyperfrontendStateMachine
Architecture Highlights§
Event handlers are edge-triggered: a handler for retrying fires on the transition into that derived state, not on every dispatch that leaves the flag true, so you get one notification per activation. Every dispatch replaces the state object rather than mutating it, so the state a subscriber receives can be compared by reference; getState() returns a fresh shallow copy on each call and is not a reference to compare against.
For a detailed technical deep dive, see the architecture guide.
API Reference§
Module Structure
12 modules · 88 total exports
State machine library with async operations, events, lifecycle components, and state management.
ƒ Functions
Parameters
| Name | Type | Description |
|---|---|---|
§payload? | T | Optional payload data |
Returns
{ payload: T; type: string }Example
Creating a cancel action
const action = cancel({ reason: 'user-requested' })
// => { type: 'CANCEL', payload: { reason: 'user-requested' } }Returns
StateExample
Creating initial state
const state = createInitialState()
// => { inProgress: false, success: false, fail: false, halt: false }Parameters
| Name | Type | Description |
|---|---|---|
§error? | any | Optional error information |
Returns
{ error: any; type: string }Example
Creating a fail action
const action = fail(new Error('Network timeout'))
// => { type: 'FAIL', error: Error('Network timeout') }Parameters
| Name | Type | Description |
|---|---|---|
§payload? | T | Optional payload data |
Returns
{ payload: T; type: string }Example
Creating a pause action
const action = pause({ resumeAt: Date.now() + 5000 })
// => { type: 'PAUSE', payload: { resumeAt: 1712345678 } }Parameters
Returns
StateExample
Processing an action
const state = rootReducer(undefined, start())
// => { inProgress: true, success: false, fail: false, halt: false }Parameters
| Name | Type | Description |
|---|---|---|
§payload? | T | Optional payload data |
Returns
{ payload: T; type: string }Example
Creating a start action
const action = start({ taskId: 'download-123' })
// => { type: 'START', payload: { taskId: 'download-123' } }Parameters
| Name | Type | Description |
|---|---|---|
§payload? | T | Optional payload data |
Returns
{ payload: T; type: string }Example
Creating a success action
const action = success({ result: 'file-downloaded' })
// => { type: 'SUCCESS', payload: { result: 'file-downloaded' } }◇ Classes
Properties
Methods
cancel(): voidon(event: Event, handler: ( event: Event, current: DerivedState, previous: DerivedState) => void): voidpause(): voidProperties
Methods
Methods
clear(): voidonActiveStatusChange(callback: ActiveChangeCallback): LifecycleAwareComponentonInitializingStatusChange(callback: InitializingChangeCallback): LifecycleAwareComponentonReadyStatusChange(callback: ReadyChangeCallback): LifecycleAwareComponentonStartStatusChange(callback: StartingChangeCallback): LifecycleAwareComponentonStopStatusChange(callback: StoppingChangeCallback): LifecycleAwareComponentsetActive(active: boolean): voidsetInitializing(initializing: boolean): voidsetReady(ready: boolean): voidsetStarting(starting: boolean): voidsetStopping(stopping: boolean): voidProperties
Methods
addItem(newState: DerivedState): StateChangeregisterCallback(callback: StateChangeHandler): StateChange◈ Interfaces
Properties
Properties
Properties
process cancelled:( state: State, action: { payload: unknown; type: string }) => StateHandler for cancel actionprocess completed successfully:( state: State, action: { payload: unknown; type: string }) => StateHandler for success actionprocess failed:( state: State, action: { error: any; type: string }) => StateHandler for fail action◆ Types
type ActiveChangeCallback = ( active: boolean) => voidtype AsyncProcess = ( ) => Promise< void>type InitializingChangeCallback = ( initializing: boolean) => voidtype ReadyChangeCallback = ( ready: boolean) => voidtype StartingChangeCallback = ( starting: boolean) => voidtype StoppingChangeCallback = ( stopping: boolean) => voidtype Event = indexedAccesstype StateDeriver = ( state: State) => DerivedStatetype StateStatusDeriver = ( state: State) => boolean● Variables
Action creators and type constants for async process lifecycle management.
ƒ Functions
Parameters
| Name | Type | Description |
|---|---|---|
§payload? | T | Optional payload data |
Returns
{ payload: T; type: string }Example
Creating a cancel action
const action = cancel({ reason: 'user-requested' })
// => { type: 'CANCEL', payload: { reason: 'user-requested' } }Parameters
| Name | Type | Description |
|---|---|---|
§error? | any | Optional error information |
Returns
{ error: any; type: string }Example
Creating a fail action
const action = fail(new Error('Network timeout'))
// => { type: 'FAIL', error: Error('Network timeout') }Parameters
| Name | Type | Description |
|---|---|---|
§payload? | T | Optional payload data |
Returns
{ payload: T; type: string }Example
Creating a pause action
const action = pause({ resumeAt: Date.now() + 5000 })
// => { type: 'PAUSE', payload: { resumeAt: 1712345678 } }Parameters
| Name | Type | Description |
|---|---|---|
§payload? | T | Optional payload data |
Returns
{ payload: T; type: string }Example
Creating a start action
const action = start({ taskId: 'download-123' })
// => { type: 'START', payload: { taskId: 'download-123' } }Parameters
| Name | Type | Description |
|---|---|---|
§payload? | T | Optional payload data |
Returns
{ payload: T; type: string }Example
Creating a success action
const action = success({ result: 'file-downloaded' })
// => { type: 'SUCCESS', payload: { result: 'file-downloaded' } }● Variables
Async operation management with state tracking for single asynchronous processes.
◇ Classes
Properties
Methods
cancel(): voidon(event: Event, handler: ( event: Event, current: DerivedState, previous: DerivedState) => void): voidpause(): void◆ Types
type AsyncProcess = ( ) => Promise< void>Coordinated async process for orchestrating multiple related async operations.
◇ Classes
Properties
Methods
Event-driven state management with pub/sub patterns.
Lifecycle-aware component with initialization, ready, starting, stopping, and active callbacks.
◇ Classes
Methods
clear(): voidonActiveStatusChange(callback: ActiveChangeCallback): LifecycleAwareComponentonInitializingStatusChange(callback: InitializingChangeCallback): LifecycleAwareComponentonReadyStatusChange(callback: ReadyChangeCallback): LifecycleAwareComponentonStartStatusChange(callback: StartingChangeCallback): LifecycleAwareComponentonStopStatusChange(callback: StoppingChangeCallback): LifecycleAwareComponentsetActive(active: boolean): voidsetInitializing(initializing: boolean): voidsetReady(ready: boolean): voidsetStarting(starting: boolean): voidsetStopping(stopping: boolean): void◆ Types
type ActiveChangeCallback = ( active: boolean) => voidtype InitializingChangeCallback = ( initializing: boolean) => voidtype ReadyChangeCallback = ( ready: boolean) => voidtype StartingChangeCallback = ( starting: boolean) => voidtype StoppingChangeCallback = ( stopping: boolean) => voidType definitions for state machine models (types exported from main entry point).
◈ Interfaces
Properties
Properties
Properties
process cancelled:( state: State, action: { payload: unknown; type: string }) => StateHandler for cancel actionprocess completed successfully:( state: State, action: { payload: unknown; type: string }) => StateHandler for success actionprocess failed:( state: State, action: { error: any; type: string }) => StateHandler for fail action◆ Types
type Event = indexedAccesstype StateDeriver = ( state: State) => DerivedStatetype StateStatusDeriver = ( state: State) => boolean● Variables
Root reducer for combining and processing state changes through action handlers.
ƒ Functions
Parameters
Returns
StateExample
Processing an action
const state = rootReducer(undefined, start())
// => { inProgress: true, success: false, fail: false, halt: false }State selectors for querying async process states like in-progress, done, failed, and paused.
● Variables
Initial state factory for creating state machine initial state objects.
ƒ Functions
Returns
StateExample
Creating initial state
const state = createInitialState()
// => { inProgress: false, success: false, fail: false, halt: false }State change tracking and management for state transitions.
◇ Classes
Properties
Methods
addItem(newState: DerivedState): StateChangeregisterCallback(callback: StateChangeHandler): StateChangeStore implementation for state machine: the dispatch/subscribe store and its listener type.
◇ Classes
◆ Types
Related reading§
- Architecture
Architecture
How @hyperfrontend/state-machine is put together, and why.
- How-to
How to make a service safe to use before it is ready
A shared service with async setup only ever serves one of its two callers: whoever subscribed early waits for a signal, and whoever subscribed late missed the signal that already fired, so I end up guarding every call site with an isReady check that can go stale between the check and the subscribe.
- How-to
How to track an async task without loading flags
isLoading and error cannot tell a background refresh from a first load or a retry from a fresh failure, so my spinner hides rows I already had and my error banner vanishes the moment someone clicks retry.
- Getting started
Getting Started
Set HyperFrontend up and embed a first feature.
- Architecture
Architecture Guide
How the packages fit together.

