PraxisJS

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/fsm
pnpm add @praxisjs/fsm
yarn add @praxisjs/fsm
bun 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>
    )
  }
}
Storybook
Live demo — traffic light
Storybook
Live demo — async fetch states

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

MethodReturnsDescription
state()SCurrent state — reactive signal, use in {() => ...} in JSX
history()Array<{from, event, to}>Full transition history
is(state)booleanTrue if the current state matches
can(event)booleanTrue if the event is valid from the current state and any guard passes
send(event)booleanTrigger a transition — returns true if it happened
reset()voidReturn 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.

Storybook
Live demo — guarded wizard form

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:

  1. Guard checked (aborts if false)
  2. onExit on the current state
  3. State updated
  4. History updated
  5. Global onTransition
  6. Per-transition action
  7. onEnter on 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() { /* ... */ }
}

On this page