pyreon

@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.

@pyreon/machinestable

Installation

npm install @pyreon/machine
bun add @pyreon/machine
pnpm add @pyreon/machine
yarn add @pyreon/machine

Quick 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'
State machine — only valid transitions fire

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 defined
Toggle — the smallest state machine

Guards

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 idle

Guards 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() // true

Checking 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
  }
}
Traffic light — matches() drives the visual

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 listener

Transition 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 listeners

Type 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 state

Real-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)

PropertyTypeDescription
config.initialstringInitial state
config.statesRecord<string, StateConfig>State definitions with transitions

Machine instance

MethodReturnsDescription
machine()TStateRead the current state (reactive in effects / computeds / JSX)
machine.send(event, payload?)TStateTrigger 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)booleanWhether the current state is one of the given states (reactive)
machine.can(event, payload?)booleanWhether 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()booleanWhether the current state is marked final (reactive)
machine.reset()voidReturn to the initial state (re-runs the initial always cascade)
machine.onEnter(state, callback)() => voidFire on entering state; returns an unsubscribe
machine.onExit(state, callback)() => voidFire on LEAVING state (before the next state's onEnter); returns an unsubscribe
machine.onTransition(callback)() => voidFire on any transition with (from, to, event); returns an unsubscribe
machine.onDone(callback)() => voidFire on entering any final state; returns an unsubscribe
machine.dispose()voidRemove 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 }
Machine