Skip to content

State Machines

Finite state machines via decorators. @StateMachine attaches a reactive machine to a component class, and @Transition binds methods to state transitions.

sh
npm install @praxisjs/fsm
sh
pnpm add @praxisjs/fsm
sh
yarn add @praxisjs/fsm
sh
bun add @praxisjs/fsm

@StateMachine(definition, property?)

Attaches a state machine to the class. Creates a this.machine property (configurable) with the reactive machine instance.

tsx
import { StateMachine, Transition } from '@praxisjs/fsm'

type State = 'idle' | 'loading' | 'success' | 'error'
type Event = 'FETCH' | 'RESOLVE' | 'REJECT' | 'RESET'

@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' } },
  },
})
@Component()
class DataFetcher extends StatefulComponent {
  @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>
    )
  }
}

The second argument to @StateMachine sets the property name (default: "machine"). Each instance gets its own isolated machine.


@Transition(property, event)

Wraps a method so it only runs when the named event causes a transition. If the event is invalid in the current state, the method body is skipped.

tsx
@Transition('machine', 'OPEN')
open() {
  // runs only when the machine transitions on 'OPEN'
  this.animateIn()
}

@Transition('machine', 'CLOSE')
close() {
  this.animateOut()
}

Calling this.open() internally does machine.send('OPEN') — if the transition succeeds, the method body runs; otherwise it's a no-op.


Machine instance API

Accessed via this.machine (or the configured property name).

MethodReturnsDescription
state()SCurrent state — reactive, use in JSX arrow functions
history()Array<{from, event, to}>Full transition history
is(state)booleanTrue if current state matches
can(event)booleanTrue if event is valid from current state
send(event)booleanTrigger a transition — returns true if it happened
reset()voidReturn to initial state

Per-state lifecycle hooks

Define onEnter and onExit callbacks directly in the state map:

tsx
@StateMachine<State, Event>({
  initial: 'idle',
  states: {
    loading: {
      on: { RESOLVE: 'success', REJECT: 'error' },
      onEnter() { console.log('started loading') },
      onExit()  { console.log('done loading') },
    },
    success: { on: { RESET: 'idle' } },
    error:   { on: { RESET: 'idle' } },
    idle:    { on: { FETCH: 'loading' } },
  },
  onTransition(from, event, to) {
    analytics.track(`fsm:${from}--${event}->${to}`)
  },
})

onEnter/onExit fire per-state. onTransition fires on every transition.


Multiple machines

Use different property names to attach more than one machine to a class:

tsx
@StateMachine({ initial: 'closed', states: { ... } }, 'drawer')
@StateMachine({ initial: 'idle',   states: { ... } }, 'upload')
@Component()
class FilePanel extends StatefulComponent {
  @Transition('drawer', 'OPEN')
  openDrawer() { /* ... */ }

  @Transition('upload', 'START')
  startUpload() { /* ... */ }
}

Released under the MIT License.