Machine — Finite State Machines & Statecharts
1.88 kB minified + gzipped
Managing multi-step workflows like order checkouts, document approvals, or user authentication with independent boolean flags quickly leads to fragile code:
Every boolean flag added to an interface doubles the number of implicit states. Six boolean flags
create 2^N (64) possible combinations — most of which represent impossible or invalid states. The
compiler cannot prevent bugs like isLoading: true while receiptUrl is already populated, or
jumping straight from cart into payment without valid shipping details. Untyped switch/case
reducers permit arbitrary state jumps and provide no protection against race conditions when
background async tasks resolve after a user has already navigated away.
Machine models workflows as explicit discriminated variant-states where each state carries only
the payload valid for that step, enforces outbound transition targets at compile time, and
automatically aborts in-flight tasks upon exiting a state.
stateDiagram-v2 direction TB [*] --> Cart Cart --> Cart : ADD_ITEM Cart --> Shipping : SET_SHIPPING Cart --> Cancelled : CANCEL Shipping --> Payment : PROCEED_PAYMENT Shipping --> Cart : BACK_TO_CART Shipping --> Cancelled : CANCEL Payment --> Completed : PAYMENT_SUCCESS Payment --> Shipping : BACK_TO_SHIPPING Completed --> [*] Cancelled --> [*]
Defining States and Events
Section titled “Defining States and Events”Every state variant is constructed using Machine.State<Kind, Payload, AllowedTargets>. The third
generic argument AllowedTargets constrains which state kinds can be returned by transition
handlers for that state:
Creating a Machine: Machine.make
Section titled “Creating a Machine: Machine.make”Define the initial state and transition table with Machine.make. Handlers receive the
strongly-typed active state and event payload. The compiler enforces that transition handlers only
return states permitted by AllowedTargets:
Pure Pipelines: Machine.transition
Section titled “Pure Pipelines: Machine.transition”In pipelines and functional compositions, use Machine.transition. It is a pure, curried data-last
transition function (machine, event) => (state) => nextState with zero side effects:
If an event is not handled by the current state, Machine.transition returns the state reference
unchanged.
Pattern Matching: Machine.match and Machine.is
Section titled “Pattern Matching: Machine.match and Machine.is”Eliminate states cleanly using Machine.match. Every variant declared in States must be handled,
and each callback receives its specific narrowed payload:
For imperative conditional logic, use the curried type guard Machine.is:
Integration with React: useReducer
Section titled “Integration with React: useReducer”Standard React reducers use the uncurried signature (state, action) => nextState. Use
Machine.to.reducer to obtain a reducer directly compatible with React’s useReducer:
Integration with Redux
Section titled “Integration with Redux”Redux stores initialize by dispatching an internal action with an undefined initial state.
Machine.to.reducer automatically detects undefined and initializes the store with
machine.initial:
Stateful Execution & Async Lifecycles: Machine.interpret
Section titled “Stateful Execution & Async Lifecycles: Machine.interpret”Pure reducers and Machine.transition compute next states synchronously. While this suffices for
local UI data transformations, real-world workflows depend on asynchronous operations:
authenticating with an API, charging payment cards, polling background jobs, or awaiting user
inputs.
Managing asynchronous side effects outside the state machine (such as inside ad-hoc component effect hooks or detached async functions) introduces three classic race conditions:
- Stale callbacks overwriting active state: If a user submits an authentication form, cancels the dialog, and submits a different email, a delayed response from the first request can resolve after cancellation and log the user into the wrong account.
- Dangling network requests and timers: When a user transitions away from a screen or aborts a flow, in-flight HTTP requests and active intervals continue running in the background unless manually cancelled, consuming network bandwidth and leaking memory.
- Re-entrant dispatch corruption: If an event handler synchronously triggers another event dispatch, naive recursive handlers update state out of order, leading to corrupted state snapshots or call stack exhaustion.
Machine.interpret turns a pure machine specification into an active execution actor that manages
the lifecycle of asynchronous effects with three guarantees:
- State-scoped cancellation via
AbortSignal: Each state’sentryhook receives anAbortSignal. The actor aborts this signal the moment the machine exits that state or whenactor.abort()is called, cancelling in-flight requests and timers automatically. - Generation token suppression: Each state transition increments an internal generation counter. If an asynchronous promise callback from a superseded state eventually settles, the actor detects the stale generation token and ignores the dispatch as an idempotent no-op.
- Sequential FIFO dispatch queue: When
sendis invoked synchronously within a transition handler or entry hook, the event is queued and evaluated only after the active transition finishes, guaranteeing strict causal event order.
Providing an optional id labels both blueprint definitions ([object Machine(auth-flow)]) and
runtime actor instances ([object Machine.Actor(auth-instance-1)[auth-flow]]) for inspection and
telemetry. Any synchronous exception thrown inside lifecycle hooks or transition handlers is caught
and routed to onError with contextual metadata (actorId, state, and phase).
Synchronization with EventBus
Section titled “Synchronization with EventBus”In distributed architectures, event producers and state consumers are separated across module boundaries. Background WebSocket connections, worker threads, keyboard listeners, and analytics trackers emit events continuously.
EventBusacts as a broadcast highway: It decouples event producers from consumers, distributing typed messages across subsystems without hardcoded references. However, an event bus maintains no workflow state — it delivers every emitted message to every registered subscriber regardless of whether the application is ready to process it.Machineacts as a stateful gatekeeper: It enforces workflow rules, ignores events that are invalid for the active state variant, and binds asynchronous side-effect lifecycles to state transitions.
Passing bus to Machine.interpret connects the broadcast highway directly to the stateful
gatekeeper:
Architectural Mechanics of the Bridge
Section titled “Architectural Mechanics of the Bridge”-
Shared Discriminated Union Protocol: Both
EventBus.Message<Events>andMachine.Event<Events>adhere to the identical{ kind: K, value: Events[K] }discriminated union structure. When initialized with{ bus }, the actor registers an internal listener with the bus. Any message emitted to the bus enters the actor’s FIFO dispatch queue without glue code or data mapping. -
Contextual Event Filtering: When an event arrives from the bus, the actor delivers it to the machine’s transition table for the active state. If the active state variant has no handler defined for that event kind,
Machine.transitionignores the event and returns the current state reference unchanged. The event bus does not need to know the machine’s internal state, and unhandled messages are discarded as safe, idempotent no-ops. -
Bound Lifecycle Teardown: Calling
actor.abort()cleans up all active state resources in a single operation: it fires the state-scopedAbortSignal, invokes the active state’s exit disposer, and unsubscribes the actor’s listener from theEventBus. This eliminates dangling listener leaks when a component or service shuts down.
Technical Failure Modes Prevented
Section titled “Technical Failure Modes Prevented”-
Out-of-Order Packet and Event Corruption: In distributed or real-time systems, network packets frequently arrive out of order (such as a
REMOTE_PATCHarriving before connection negotiation finishes, or a late update arriving after disconnection). Without a state machine, premature or delayed events corrupt application state.Machineacts as a firewall: any event not explicitly permitted by the active state variant is dropped immediately. -
Dangling Subscription and Memory Leaks: Manually wiring event bus listeners to UI component state requires tracking unsubscribe tokens and calling them across unmount boundaries. Passing
{ bus }toMachine.interpretbinds subscription lifecycles directly to the actor. Invokingactor.abort()unbinds the actor from the bus, preventing retain cycles and memory leaks. -
Cascading Exception Containment:
EventBusutilizes a breadth-first trampoline dispatch loop with error boundaries (onError). If a machine’s transition handler or entry hook throws an unhandled exception,EventBuscatches the error, isolates it, and routes it toonError. The failure is contained, preventing a broken state transition from crashing sibling subscribers or halting the broader event bus pipeline.
Problems it solves
Section titled “Problems it solves”- Impossible state combinations from boolean flag proliferation: In complex workflows like
checkout or user onboarding, managing status with multiple independent boolean flags (
isLoading,isError,isSubmitted) creates 2^N implicit states. The compiler cannot prevent invalid combinations likeisLoading: truewhile an order receipt is already present.Machinereplaces disconnected flags with mutually exclusive discriminated union variants, ensuring only the payload valid for the active step exists in memory. - Illegal state jumps and broken workflow transitions: Untyped
switch/casereducers allow any action to transition into any state, making it possible for buggy UI code to jump directly from cart to payment confirmation without capturing an address.Machine.Statelets you declare explicit outbound target bounds (AllowedTargets) so TypeScript flags illegal transitions at compile time before code ever runs. - Async race conditions, stale callbacks, and memory leaks: When asynchronous requests are
initiated from UI components or loose effects, late network responses can resolve after a user has
cancelled or navigated away, overwriting fresh data with stale results.
Machine.interpretpasses a state-scopedAbortSignaldirectly to entry hooks, aborting in-flight requests immediately on state exit and dropping stale callbacks via internal generation counters. - Bridging pure functional pipelines with standard UI stores: Functional pipelines favor curried
data-last signatures
(event) => (state) => nextState, whereas React’suseReducerand Redux require uncurried(state, action) => nextStatefunctions that handleundefinedinitial states.Machine.to.reducerbridges this gap, allowing pure statecharts to power standard React components and Redux stores with zero wrapper glue. - Re-entrant dispatch corruption and call stack overflow: When an event handler or lifecycle
hook synchronously triggers another event dispatch, recursive event loops can scramble event
ordering or trigger stack overflows.
Machine.interpretmanages an internal FIFO dispatch queue, ensuring synchronous events are processed sequentially in exact causal order.