@pyreon/machine provides reactive state machines — constrained signals that can only hold specific values and transition between them via specific events. Replace nested booleans with explicit states and type-safe transitions.
Installation
npm install @pyreon/machinebun add @pyreon/machinepnpm add @pyreon/machineyarn add @pyreon/machineQuick Start
import { createMachine } from '@pyreon/machine'
const machine = createMachine({
initial: 'idle',
states: {
idle: { on: { FETCH: 'loading' } },
loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
done: {},
error: { on: { RETRY: 'loading' } },
},
})
machine() // 'idle' — reads like a signal
machine.send('FETCH')
machine() // 'loading'Why State Machines?
State machines prevent impossible states. Compare:
// ❌ Nested booleans — 16 possible combinations, most invalid
const isLoading = signal(false)
const isError = signal(false)
const isSuccess = signal(false)
const isOpen = signal(false)
// What does isLoading=true + isSuccess=true mean? 🤷
// ✅ State machine — only valid states exist
const dialog = createMachine({
initial: 'closed',
states: {
closed: { on: { OPEN: 'confirming' } },
confirming: { on: { CONFIRM: 'loading', CANCEL: 'closed' } },
loading: { on: { SUCCESS: 'success', ERROR: 'error' } },
success: { on: { CLOSE: 'closed' } },
error: { on: { RETRY: 'loading', CLOSE: 'closed' } },
},
})Reading State
The machine instance is callable — it reads like a signal and is reactive in effects, computeds, and JSX:
const machine = createMachine({
initial: 'idle',
states: {
idle: { on: { START: 'running' } },
running: { on: { STOP: 'idle', PAUSE: 'paused' } },
paused: { on: { RESUME: 'running', STOP: 'idle' } },
},
})
// Read current state
machine() // 'idle'
// Reactive in JSX
function StatusBadge() {
return <span>{machine()}</span>
}Sending Events
Transition between states by sending events:
machine.send('START') // idle → running
machine.send('PAUSE') // running → paused
machine.send('RESUME') // paused → running
machine.send('STOP') // running → idle
// With payload
machine.send('SELECT', { id: 42 })
// Invalid events are silently ignored
machine.send('PAUSE') // ignored when in 'idle' — no transition definedGuards
Use guards for conditional transitions:
const form = createMachine({
initial: 'editing',
states: {
editing: {
on: {
SUBMIT: { target: 'submitting', guard: () => isValid() },
SAVE_DRAFT: 'saving',
},
},
submitting: { on: { SUCCESS: 'done', ERROR: 'editing' } },
saving: { on: { SAVED: 'editing' } },
done: {},
},
})
// SUBMIT only transitions if guard returns true
form.send('SUBMIT') // ignored if isValid() is false
// Guards can also receive the event payload
const transfer = createMachine({
initial: 'idle',
states: {
idle: {
on: {
SEND: { target: 'confirming', guard: (payload) => payload.amount > 0 },
},
},
confirming: { on: { CONFIRM: 'done', CANCEL: 'idle' } },
done: {},
},
})
transfer.send('SEND', { amount: 100 }) // guard passes → confirming
transfer.send('SEND', { amount: 0 }) // guard fails → stays idleGuards are throw-safe: a guard that throws is treated as "denied" (no transition) rather than crashing send() / can(). That lets can(event) be called without a payload against a payload-reading guard, and keeps a buggy guard from taking down a dispatch.
Eventless (always) Transitions
An always transition is evaluated immediately on entering a state (and for the initial state at creation) — no event required. The first one whose guard passes (or the first unguarded one) fires synchronously, then the new state's always is re-evaluated. Use it for transient / branch states that immediately route based on a condition:
const checkout = createMachine({
initial: 'validating',
states: {
// Transient: routes the moment it's entered, based on external signals.
validating: {
always: [
{ target: 'empty', guard: () => cart().length === 0 },
{ target: 'needsLogin', guard: () => !user() },
'ready', // first unguarded → the fallback branch
],
},
empty: { on: { ADD: 'validating' } },
needsLogin: { on: { LOGIN: 'validating' } },
ready: { on: { PAY: 'paying' } },
paying: { on: { DONE: 'done' } },
done: {},
},
})
// `send` returns the SETTLED state after the cascade — not the intermediate.
checkout.send('ADD') // empty → validating → (cascade) → 'ready' (or 'needsLogin')always guards receive no payload — branch on external signals (cart(), user()), not event data. The cascade is bounded (an infinite always loop is capped and throws in dev rather than hanging).
Final States
Mark a terminal state with final: true. isFinal() reads true while there, and onDone listeners fire on entry:
const job = createMachine({
initial: 'running',
states: {
running: { on: { FINISH: 'complete', FAIL: 'failed' } },
complete: { final: true },
failed: { final: true, on: { RETRY: 'running' } },
},
})
job.isFinal() // false (in 'running')
job.onDone((event) => console.log('reached a final state via', event.type))
job.send('FINISH')
job.isFinal() // trueChecking State
matches()
Check if the machine is in one or more states — reactive in JSX and effects:
machine.matches('loading') // true if in 'loading'
machine.matches('success', 'error') // true if in either
// Reactive rendering
function App() {
return () => {
if (machine.matches('idle')) return <button onClick={() => machine.send('FETCH')}>Load</button>
if (machine.matches('loading')) return <Spinner />
if (machine.matches('error')) return <ErrorView onRetry={() => machine.send('RETRY')} />
if (machine.matches('done')) return <DataView />
return null
}
}can()
Check if an event would trigger a valid transition from the current state:
machine.can('FETCH') // true if FETCH is defined in current state's transitions
// Disable buttons for invalid actions
<button disabled={!machine.can('SUBMIT')} onClick={() => machine.send('SUBMIT')}>
Submit
</button>nextEvents()
Get all available events from the current state:
machine.nextEvents() // ['FETCH', 'RESET'] — depends on current state
// Useful for command palettes or help dialogs
const availableActions = machine.nextEvents()Side Effects with onEnter
Fire a callback when the machine enters a specific state:
const fetchMachine = createMachine({
initial: 'idle',
states: {
idle: { on: { FETCH: 'loading' } },
loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
done: {},
error: { on: { RETRY: 'loading' } },
},
})
const data = signal(null)
const error = signal(null)
// Side effect — fetch when entering 'loading'
fetchMachine.onEnter('loading', async () => {
try {
const result = await fetch('/api/data').then((r) => r.json())
data.set(result)
fetchMachine.send('SUCCESS')
} catch (e) {
error.set(e)
fetchMachine.send('ERROR')
}
})onEnter returns an unsubscribe function:
const unsub = machine.onEnter('loading', () => { ... })
unsub() // remove the listenerTransition Listener
React to any transition:
machine.onTransition((from, to, event) => {
console.log(`${from} → ${to} via ${event.type}`)
analytics.track('state_change', { from, to, event: event.type })
})Leaving a State with onExit
The mirror of onEnter — fires when the machine LEAVES a state, before the next state's onEnter. Use it to tear down what onEnter set up (timers, subscriptions):
const room = createMachine({
initial: 'lobby',
states: {
lobby: { on: { JOIN: 'connected' } },
connected: { on: { LEAVE: 'lobby' } },
},
})
let socket: WebSocket | undefined
room.onEnter('connected', () => {
socket = new WebSocket('wss://chat.example')
})
room.onExit('connected', () => {
socket?.close() // cleaned up before re-entering 'lobby'
})Both onEnter and onExit return an unsubscribe function.
Reset
Return to the initial state:
machine.reset() // back to 'idle' (or whatever initial was)Cleanup
Remove all listeners:
machine.dispose() // clears all onEnter and onTransition listenersType Safety
States and events are inferred from the definition — no manual type annotations needed:
const machine = createMachine({
initial: 'idle',
states: {
idle: { on: { FETCH: 'loading', RESET: 'idle' } },
loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
done: {},
error: { on: { RETRY: 'loading' } },
},
})
machine() // type: 'idle' | 'loading' | 'done' | 'error'
machine.send('FETCH') // ✓ valid event
machine.send('FLY') // TS error — not a valid event
machine.matches('idle') // ✓ valid state
machine.matches('x') // TS error — not a valid stateReal-World Patterns
Multi-Step Wizard
const wizard = createMachine({
initial: 'step1',
states: {
step1: { on: { NEXT: 'step2' } },
step2: { on: { NEXT: 'step3', BACK: 'step1' } },
step3: { on: { SUBMIT: 'submitting', BACK: 'step2' } },
submitting: { on: { SUCCESS: 'done', ERROR: 'step3' } },
done: {},
},
})
const formData = signal({ name: '', email: '' })
wizard.onEnter('submitting', async () => {
try {
await submitData(formData())
wizard.send('SUCCESS')
} catch {
wizard.send('ERROR')
}
})
function WizardUI() {
return () => {
if (wizard.matches('step1')) return <Step1 onNext={() => wizard.send('NEXT')} />
if (wizard.matches('step2'))
return <Step2 onNext={() => wizard.send('NEXT')} onBack={() => wizard.send('BACK')} />
if (wizard.matches('step3'))
return <Step3 onSubmit={() => wizard.send('SUBMIT')} onBack={() => wizard.send('BACK')} />
if (wizard.matches('submitting')) return <Spinner />
if (wizard.matches('done')) return <Success />
return null
}
}Auth Flow
const auth = createMachine({
initial: 'idle',
states: {
idle: { on: { LOGIN: 'authenticating' } },
authenticating: { on: { SUCCESS: 'authenticated', ERROR: 'idle' } },
authenticated: { on: { LOGOUT: 'idle' } },
},
})
const user = signal(null)
auth.onEnter('authenticating', async (event) => {
try {
const result = await login(event.payload.email, event.payload.password)
user.set(result)
auth.send('SUCCESS')
} catch {
auth.send('ERROR')
}
})
auth.onEnter('idle', () => user.set(null))File Upload
const upload = createMachine({
initial: 'idle',
states: {
idle: { on: { SELECT: 'selected' } },
selected: { on: { UPLOAD: 'uploading', CANCEL: 'idle' } },
uploading: { on: { PROGRESS: 'uploading', SUCCESS: 'done', ERROR: 'error' } },
done: { on: { RESET: 'idle' } },
error: { on: { RETRY: 'uploading', CANCEL: 'idle' } },
},
})
const progress = signal(0)
const file = signal(null)Data Alongside Machines
Machines manage transitions, signals manage data. They compose naturally:
// ✅ Signals for data, machine for state
const count = signal(0)
const error = signal<Error | null>(null)
const machine = createMachine({
initial: 'idle',
states: {
idle: { on: { INCREMENT: 'idle', SUBMIT: 'submitting' } },
submitting: { on: { SUCCESS: 'done', ERROR: 'idle' } },
done: {},
},
})
machine.onEnter('idle', (event) => {
if (event.type === 'INCREMENT') count.update((n) => n + 1)
})API Reference
createMachine(config)
| Property | Type | Description |
|---|---|---|
config.initial | string | Initial state |
config.states | Record<string, StateConfig> | State definitions with transitions |
Machine instance
| Method | Returns | Description |
|---|---|---|
machine() | TState | Read the current state (reactive in effects / computeds / JSX) |
machine.send(event, payload?) | TState | Trigger a transition; returns the SETTLED state after the transition + any always cascade — or the unchanged state if the event isn't handled or a guard rejects |
machine.matches(...states) | boolean | Whether the current state is one of the given states (reactive) |
machine.can(event, payload?) | boolean | Whether send(event, payload) would transition — evaluates the guard with payload (reactive) |
machine.nextEvents() | TEvent[] | All events with a transition defined from the current state (reactive) |
machine.isFinal() | boolean | Whether the current state is marked final (reactive) |
machine.reset() | void | Return to the initial state (re-runs the initial always cascade) |
machine.onEnter(state, callback) | () => void | Fire on entering state; returns an unsubscribe |
machine.onExit(state, callback) | () => void | Fire on LEAVING state (before the next state's onEnter); returns an unsubscribe |
machine.onTransition(callback) | () => void | Fire on any transition with (from, to, event); returns an unsubscribe |
machine.onDone(callback) | () => void | Fire on entering any final state; returns an unsubscribe |
machine.dispose() | void | Remove all listeners |
StateConfig
interface StateConfig<TState, TEvent> {
/** Event-name → transition. A bare string is an unguarded target. */
on?: Partial<Record<TEvent, TState | TransitionConfig<TState>>>
/**
* Eventless ("always") transitions — evaluated immediately on ENTERING this
* state (and for the initial state at creation). The first whose guard passes
* (or the first unguarded one) fires synchronously; then the new state's
* `always` is re-evaluated (the cascade). Guards receive NO payload — read
* external signals. Use for transient / branch states.
*/
always?: TransitionConfig<TState> | TransitionConfig<TState>[]
/** Marks a terminal state — `isFinal()` reads true and `onDone` fires on entry. */
final?: boolean
}
// A transition target: a bare state name (unguarded), or an object with a guard.
type TransitionConfig<TState> = TState | { target: TState; guard: (payload?: unknown) => boolean }