@hyperfrontend/state-machine§

Four base lamps on the left, inProgress, success, fail and halt, and seven derived names on the right joined to them by faint wires; five action chips fire in turn, start, fail, start, success, start, each dropping a token that flips lamps, and the wires from the lit lamps light the names that hold: inProgress, then failed, then retrying because fail stayed lit, then successful, then restarting because success stayed litFour base lamps on the left, inProgress, success, fail and halt, and seven derived names on the right joined to them by faint wires; five action chips fire in turn, start, fail, start, success, start, each dropping a token that flips lamps, and the wires from the lit lamps light the names that hold: inProgress, then failed, then retrying because fail stayed lit, then successful, then restarting because success stayed lit

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

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§

View:
Organized by entry point

Module Structure

|

12 modules · 88 total exports

State machine library with async operations, events, lifecycle components, and state management.

7 fn6 cls4 int10 type17 var

Action creators and type constants for async process lifecycle management.

5 fn5 var

Async operation management with state tracking for single asynchronous processes.

1 cls1 type

Coordinated async process for orchestrating multiple related async operations.

1 cls

Event-driven state management with pub/sub patterns.

1 cls

Lifecycle-aware component with initialization, ready, starting, stopping, and active callbacks.

1 cls5 type

Type definitions for state machine models (types exported from main entry point).

4 int3 type1 var

Root reducer for combining and processing state changes through action handlers.

1 fn

State selectors for querying async process states like in-progress, done, failed, and paused.

11 var

Initial state factory for creating state machine initial state objects.

1 fn

State change tracking and management for state transitions.

1 cls

Store implementation for state machine: the dispatch/subscribe store and its listener type.

1 cls1 type

Browse guides filtered to this packageSuggest a guide