Documentation

Finite State Machines

A tiny, type-safe statechart engine built on top of Alwatr Signals.

@alwatr/fsm replaces ad-hoc boolean flags — isLoading, hasError, isUploading, all mutable independently and therefore all capable of contradicting each other — with a declarative statechart where only one state is ever active at a time. It's built directly on top of Alwatr Signals and is fully re-exported by @alwatr/flux.

A config-driven machine

ts
import type {StateMachineConfig} from '@alwatr/fsm';
import {createFsmService} from '@alwatr/fsm';

type FileState = 'idle' | 'uploading' | 'success' | 'failed';
interface FileContext { fileId: string | null; progress: number; }
type FileEvent =
  | {type: 'START_UPLOAD'; fileId: string}
  | {type: 'PROGRESS_UPDATE'; percent: number}
  | {type: 'UPLOAD_SUCCESS'};

const config: StateMachineConfig<FileState, FileEvent, FileContext> = {
  name: 'file-upload-lifecycle',
  initial: 'idle',
  context: {fileId: null, progress: 0},
  states: {
    idle: {
      on: {
        START_UPLOAD: {
          target: 'uploading',
          assigners: [({context, event}) => ({...context, fileId: event.fileId})],
        },
      },
    },
    uploading: {
      on: {
        // no target → internal transition: context updates, state stays 'uploading'
        PROGRESS_UPDATE: {
          assigners: [({context, event}) => ({...context, progress: event.percent})],
        },
        UPLOAD_SUCCESS: {target: 'success'},
      },
    },
    success: {},
  },
};

const fileUploadService = createFsmService(config);
fileUploadService.stateSignal.subscribe((state) => {
  console.log(state.name, state.context.progress);
});

fileUploadService.dispatch({type: 'START_UPLOAD', fileId: 'doc_102'});

Glossary

TermMeaning
StateA finite, named mode the machine can be in.
ContextArbitrary extended data carried alongside the current state.
EventA {type} message dispatched into the machine.
TransitionA rule describing how an event moves state A to state B.
AssignerA pure function that computes the next context.
EffectA fire-and-forget side-effect run on state entry or exit.
ActorA spawned async process with its own lifecycle and cleanup, tied to a state.
GuardA boolean predicate gating whether a transition may fire.

Internal vs. external transitions

Every transition runs synchronously and atomically through a queue — Run-to-Completion semantics mean concurrent dispatches can never race each other.

  • No target: an internal, context-only update. Entry/exit effects and running actors are left untouched.
  • With target (even the same state — a self-transition): a full exit → enter cycle, re-running entry/exit effects and respawning actors.

Guards, actors, and effects

An event can map to an array of {target, guard, assigners} candidates — the first whose guard passes wins; a guard-less entry acts as the fallback. Entry/exit effects are arrays of sync or async fire-and-forget functions (the FSM never awaits their return value). Actors are spawned on state entry and can dispatch events back asynchronously:

ts
uploading: {
  actors: [
    ({context, dispatch}) => {
      const controller = uploadFile(context.fileId, (percent) => {
        dispatch({type: 'PROGRESS_UPDATE', percent});
      });
      return () => controller.abort(); // cleanup, run automatically on exit
    },
  ],
},
Warning

Assigner, guard, and effect closures all run inside a try/catch — a thrown error is caught, logged, and the context update reverted. Also note the __init__ caveat: the initial state's entry effects and actors run with a synthetic {type: '__init__'} event, so handle custom event fields defensively if you read them there.

Persistence

Add a persistent: {schemaVersion, storageKey} block to the config to auto-sync state and context to storage — the same versioning story as Storage & Hydration.

Actor Model synergy

FSM is the "brain" (state + mailbox) of the Actor Model pattern described in Architecture & UDF: an input controller translates global actions into local FSM events, and FSM effects broadcast state changes back out via the global action bus — giving you fully decoupled feature modules with no direct imports between them.

API surface: createFsmService(config) → FsmService<S, E, C> exposing .stateSignal (a read-only signal of MachineState<S, C>), .dispatch(event), and .destroy().