React Integration
This example demonstrates integrating machina with React using useSyncExternalStore for a multi-step checkout flow. The FSM lives entirely outside the React component tree — zero React imports in fsm.ts — and components subscribe to state changes through React’s built-in subscription API. No wrapper library required.
What It Demonstrates
Section titled “What It Demonstrates”createFsmused outside the React component tree —fsm.tshas no React importsuseSyncExternalStorefor subscribing to FSM state transitions- Clean separation: the FSM owns state logic, React owns rendering
- No wrapper libraries — machina’s
on()/off()API maps directly to the subscribe/unsubscribe contract thatuseSyncExternalStoreexpects canHandle()for driving button disabled state rather than catching silent no-ops- Passing form data through
handle("next", formData)— components own ephemeral form state and submit it at transition time _onEnterlifecycle hooks for async side effects (payment simulation, flag management)
The Hook
Section titled “The Hook”The full React integration layer is about 40 lines. The key pieces:
The context object is shared by reference — createCheckoutFsm() returns both { fsm, context }, and the hook holds the same object the FSM mutates internally. Re-renders are triggered by state transitions, at which point the context is already up to date. Don’t clone or spread it.
The API object is memoized on state, so consumers only re-render on FSM transitions — not on every parent render:
Design Notes
Section titled “Design Notes”Why no useEffect cleanup for the FSM. React 18 StrictMode simulates unmount/remount for effects but does not re-run the component body. Disposing the FSM in a cleanup effect would leave useSyncExternalStore trying to re-subscribe to a dead FSM before the lazy ref init could recreate it. The FSM holds no external resources — when the Provider truly unmounts, the refs are GC’d.
Why the context is mutated in place, not replaced. The hook holds a ref to the original context object. If confirmation.startOver replaced context with a fresh object, all components would keep reading from the old reference. Mutating the fields in place is the simpler contract.