@pyreon/machine
v0.34.0
Published
Reactive state machines for Pyreon — constrained signals with type-safe transitions
Readme
@pyreon/machine
Reactive state machines as constrained signals — type-safe states and events.
A machine is a Pyreon signal that can only hold a fixed set of values and only transition between them via declared events. Reads (machine()) and predicates (matches(...) / can(...) / nextEvents()) are reactive in effects, computeds, and JSX. Everything else — data carried alongside state, side-effect orchestration, async — uses ordinary Pyreon primitives (signal / computed / effect); the machine owns transitions, signals own data. State and event names flow from the config object so machine.send('TYPO') is a TypeScript error.
Install
bun add @pyreon/machine @pyreon/core @pyreon/reactivityQuick start
import { createMachine } from '@pyreon/machine'
const fetcher = createMachine({
initial: 'idle',
states: {
idle: { on: { FETCH: 'loading' } },
loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
done: {},
error: { on: { RETRY: 'loading' } },
},
})
fetcher() // 'idle' — reads like a signal
fetcher.send('FETCH') // transition to 'loading'
// Reactive in JSX
function View() {
return () => (
<>
{fetcher.matches('loading') && <Spinner />}
{fetcher.matches('error') && <button onClick={() => fetcher.send('RETRY')}>Retry</button>}
{fetcher.matches('done') && <Results />}
</>
)
}API
createMachine({ initial, states }) returns a callable Machine<TState, TEvent>:
| Member | Notes |
|---|---|
| machine() | Read current state — reactive in effects / computeds / JSX |
| machine.send(event, payload?) | Trigger a transition. Silent no-op when no matching on: entry. |
| machine.matches(...states) | True if current state is in the list — reactive |
| machine.can(event) | True if send(event) would transition (guards evaluated) |
| machine.nextEvents() | Valid event names from the current state — reactive |
| machine.reset() | Return to initial |
| machine.onEnter(state, cb) | Callback fires every time state is entered. Returns unsubscribe. |
| machine.onTransition(cb) | Callback fires on any transition with (from, to, event). Returns unsubscribe. |
| machine.dispose() | Remove all listeners and clean up |
Guards — conditional transitions
const form = createMachine({
initial: 'editing',
states: {
editing: {
on: { SUBMIT: { target: 'submitting', guard: () => isValid() } },
},
submitting: { on: { SUCCESS: 'done', ERROR: 'editing' } },
done: {},
},
})
form.send('SUBMIT') // no-op if !isValid()
form.can('SUBMIT') // true only if guard passesThe guard receives the event payload: guard: (payload?: { force?: boolean }) => isValid() || payload?.force.
Listeners — onEnter / onTransition
const unsubEnter = fetcher.onEnter('loading', (event) => {
console.log('Entered loading via', event.type, event.payload)
})
const unsubAll = fetcher.onTransition((from, to, event) => {
analytics.track('state', { from, to, event: event.type })
})
// Later:
unsubEnter()
unsubAll()
// Or wipe everything:
fetcher.dispose()onEnter does NOT fire on the initial state — only on subsequent transitions INTO that state. Pair with explicit setup-time work if you need to model entering the initial state as an event.
Composing with signals
The machine owns transitions; signals own data. Use them together:
const fetcher = createMachine({
initial: 'idle',
states: {
idle: { on: { FETCH: 'loading' } },
loading: { on: { SUCCESS: 'done', ERROR: 'error' } },
done: {},
error: { on: { RETRY: 'loading' } },
},
})
const data = signal<User[]>([])
const error = signal<Error | null>(null)
effect(async () => {
if (!fetcher.matches('loading')) return
try {
data.set(await api.fetchUsers())
fetcher.send('SUCCESS')
} catch (e) {
error.set(e as Error)
fetcher.send('ERROR')
}
})
fetcher.send('FETCH')Type inference
State and event names are inferred from the config. The exported InferStates<T> and InferEvents<T> helpers extract those types when you need to pass the machine around:
import type { InferStates, InferEvents } from '@pyreon/machine'
type FetcherState = InferStates<typeof fetcher> // 'idle' | 'loading' | 'done' | 'error'
type FetcherEvent = InferEvents<typeof fetcher> // 'FETCH' | 'SUCCESS' | 'ERROR' | 'RETRY'
function logState(s: FetcherState) {
/* … */
}Gotchas
send(event)to an unknown event is a silent no-op. Usemachine.can(event)to detect whether the transition would fire (e.g. to disable a button).- Guards run on every
can(event)check as well as onsend(event). Keep guards cheap; for expensive predicates, wrap incomputedupstream and have the guard read the result. onEnterdoesn't fire for the initial state — only subsequent entries. Model "first run" as an explicit event if you need that semantics.- The machine does NOT orchestrate side effects. Use
effect(or async flow inside an effect) that readsmachine.matches(...)and reacts. Keeps the machine pure. dispose()is final — after it runs, every method becomes a no-op and listeners are dropped. Don't reuse a disposed machine.- States with no
on:map are terminal for the framework's purposes —nextEvents()returns[]and everysend()is a no-op until something else (a manualreset()or another machine) brings it out.
Documentation
Full docs: pyreon.dev/docs/machine (or docs/src/content/docs/machine.md in this repo).
License
MIT
