State Machines
@praxisjs/fsm — finite state machines via @StateMachine and @Transition decorators, with typed states/events, reactive state signal, guards, per-transition actions, and per-state lifecycle hooks.
State Machines
Finite state machines via decorators. @StateMachine attaches a reactive machine to a class, and @Transition binds methods to valid state transitions — the method body only runs if the transition is allowed in the current state.
npm install @praxisjs/fsmpnpm add @praxisjs/fsmyarn add @praxisjs/fsmbun add @praxisjs/fsm@StateMachine(definition)
Field decorator. Attaches a reactive machine to the decorated field. Each component instance gets its own isolated machine. The field declaration also provides the TypeScript type — no extra declare needed.
import { StateMachine, Transition } from '@praxisjs/fsm'
import type { Machine } from '@praxisjs/fsm'
type State = 'idle' | 'loading' | 'success' | 'error'
type Event = 'FETCH' | 'RESOLVE' | 'REJECT' | 'RESET'
@Component()
class DataFetcher extends StatefulComponent {
@StateMachine<State, Event>({
initial: 'idle',
states: {
idle: { on: { FETCH: 'loading' } },
loading: { on: { RESOLVE: 'success', REJECT: 'error' } },
success: { on: { RESET: 'idle' } },
error: { on: { RESET: 'idle', FETCH: 'loading' } },
},
})
machine!: Machine<State, Event>
@State() data: Result | null = null
@Transition('machine', 'FETCH')
async load() {
try {
this.data = await api.fetch()
this.machine.send('RESOLVE')
} catch {
this.machine.send('REJECT')
}
}
render() {
return (
<div>
{() => this.machine.is('loading') && <Spinner />}
{() => this.machine.is('error') && <p>Something went wrong.</p>}
{() => this.machine.is('success') && <Results data={this.data} />}
<button
disabled={() => !this.machine.can('FETCH')}
onClick={() => this.load()}
>
Fetch
</button>
</div>
)
}
}@Transition(property, event)
Wraps a method so it only executes when the named event causes a valid transition in the machine. If the event is not valid from the current state (or a guard blocks it), the method body is skipped silently.
@Transition('machine', 'OPEN')
open() {
this.animateIn() // only runs if 'OPEN' is valid from the current state
}
@Transition('machine', 'CLOSE')
close() {
this.animateOut()
}Calling this.open() internally sends machine.send('OPEN') — if the transition succeeds, the method body runs; otherwise it's a no-op.
Machine API
Access via this.machine (or the configured property name).
| Method | Returns | Description |
|---|---|---|
state() | S | Current state — reactive signal, use in {() => ...} in JSX |
history() | Array<{from, event, to}> | Full transition history |
is(state) | boolean | True if the current state matches |
can(event) | boolean | True if the event is valid from the current state and any guard passes |
send(event) | boolean | Trigger a transition — returns true if it happened |
reset() | void | Return to the initial state |
Guards
A guard is a function on a transition that must return true for the transition to proceed. If the guard returns false, send() returns false and nothing happens — onExit, history, and onEnter are all skipped.
To add a guard, replace the plain state string with a TransitionTarget object:
states: {
editing: {
on: {
SUBMIT: { target: 'submitting', guard: () => formIsValid },
},
},
}can(event) also evaluates the guard, so you can disable UI elements reactively:
<button disabled={() => !this.machine.can('SUBMIT')}>Submit</button>Guards with instance access
Pass the component class as the third generic to @StateMachine<S, E, T>. Guards and actions then receive the component instance as their first argument:
@StateMachine<State, Event, MyForm>({
initial: 'editing',
states: {
editing: {
on: {
SUBMIT: {
target: 'submitting',
guard: (self) => self.name.trim().length > 0 && self.email.includes('@'),
action: (self) => self.clearErrors(),
},
},
},
submitting: { on: { DONE: 'success', FAIL: 'error' } },
success: { on: { RESET: 'editing' } },
error: { on: { RESET: 'editing' } },
},
})
machine!: Machine<State, Event>Guards are re-evaluated on every send() call — they read the live field values at transition time.
Per-transition action
An action is a side-effect callback on a specific transition. It runs after the state is committed and onTransition fires, but before onEnter. Like guards, it receives the component instance when the third generic is provided:
states: {
idle: {
on: {
START: {
target: 'running',
guard: (self) => self.isReady(),
action: (self) => self.timer.start(),
},
},
},
}Per-state lifecycle hooks
Define onEnter and onExit callbacks directly in the state definition. Both receive an optional context with the triggering event and the from/to state:
@StateMachine<State, Event>({
initial: 'idle',
states: {
loading: {
on: { RESOLVE: 'success', REJECT: 'error' },
onEnter({ event, from }) {
console.log(`entered loading via ${event} from ${from}`)
},
onExit({ event, to }) {
console.log(`left loading via ${event}, heading to ${to}`)
},
},
success: { on: { RESET: 'idle' } },
error: { on: { RESET: 'idle' } },
idle: { on: { FETCH: 'loading' } },
},
onTransition(from, event, to) {
analytics.track(`fsm:${from}--${event}->${to}`)
},
})
machine!: Machine<State, Event>The context parameter is optional — zero-argument callbacks continue to work unchanged.
Execution order for a successful transition:
- Guard checked (aborts if
false) onExiton the current state- State updated
- History updated
- Global
onTransition - Per-transition
action onEnteron the new state
Multiple machines on one class
Decorate multiple fields — each field name becomes the machine reference used in @Transition:
@Component()
class FilePanel extends StatefulComponent {
@StateMachine({ initial: 'closed', states: { /* ... */ } })
drawer!: Machine<'closed' | 'open', 'OPEN' | 'CLOSE'>
@StateMachine({ initial: 'idle', states: { /* ... */ } })
upload!: Machine<'idle' | 'uploading' | 'done', 'START' | 'FINISH'>
@Transition('drawer', 'OPEN')
openDrawer() { /* ... */ }
@Transition('upload', 'START')
startUpload() { /* ... */ }
}