TL;DR / Finite state machines model UI behavior as a set of explicit states with defined transitions between them, making impossible states unrepresentable and eliminating entire categories of UI bugs.
How It Works
┌──────────┐ FETCH ┌──────────┐ OK ┌──────────┐
│ Idle │─────────→│ Loading │─────────→│ Success │
└──────────┘ └──────────┘ └──────────┘
↑ │
│ │
│ RETRY │
└─────────────┐ FAIL
│ │
│ │
│ ↓
│ ┌──────────┐
└─│ Error │
└──────────┘
The typical approach to UI state management uses independent boolean flags: isLoading, isError, isSuccess, hasData. With three booleans, you have eight possible combinations, but only four are valid (idle, loading, error, success). The other four combinations -- like isLoading: true AND isError: true -- are impossible states that your code must defensively handle or risk rendering nonsensical UI. Finite state machines eliminate this problem by design.
State Machines vs Boolean Flags
A state machine defines the system as being in exactly one state at any time. Transitions between states are triggered by events and are explicitly enumerated. If you are in the loading state, only certain events are valid: RESOLVE transitions to success, REJECT transitions to error. The FETCH event is not valid when already loading -- the machine simply ignores it. There is no code path where the system is simultaneously loading and errored.
Compare this to boolean-based state where every event handler must consider every flag combination. A fetch() call sets isLoading = true, but what if isError is already true from a previous failure? Should it clear the error? Should it prevent the fetch? These decisions are scattered across handlers rather than centralized in a transition table.
Statecharts: Hierarchical State Machines
Basic state machines become unwieldy as complexity grows. Statecharts (invented by David Harel in 1987) extend state machines with hierarchy, parallelism, and history. A form state can have nested states valid and invalid. A page statechart can have parallel regions for data (loading/loaded/error) and ui (modal-open/modal-closed) that transition independently.
This hierarchy eliminates state explosion. Instead of loadingWithModalOpen, loadingWithModalClosed, errorWithModalOpen, etc., you have two independent parallel regions. The total state is the combination of each region's current state, but the transition logic is defined per-region.
XState and the Actor Model
XState is the dominant state machine library in the frontend ecosystem. It implements statecharts with the actor model, where each machine is an actor that can spawn child actors, send messages, and manage its own state. A form machine spawns a validation actor and a submission actor. The parent coordinates them through events.
XState machines are serializable -- the configuration is a plain JavaScript object (or JSON). This enables visual tooling: XState's inspector and Stately.ai's visual editor render the state machine as a diagram, making the logic auditable by non-developers. You can verify that every state has appropriate exit transitions and no state is a dead end.
Practical Application
Form handling: States are editing, validating, submitting, success, error. The SUBMIT event only transitions from editing if validation passes (a guard condition). During submitting, the submit button is disabled not because of an isSubmitting boolean but because the submitting state has no SUBMIT transition. Double submission is impossible by definition.
Authentication flows: States are unauthenticated, authenticating, authenticated, refreshing. Token refresh transitions from authenticated to refreshing and back. The LOGOUT event is handled in every authenticated state but ignored in unauthenticated. No defensive if (user) checks needed.
Multi-step wizards: Each step is a state with NEXT and BACK transitions. Steps can have guard conditions (validate before moving forward). The machine guarantees you cannot jump to step 3 from step 1 because no such transition exists.
Type Safety
TypeScript and state machines are a powerful combination. Discriminated unions model the state: type State = {status: 'idle'} | {status: 'loading'} | {status: 'success', data: T} | {status: 'error', error: Error}. The data property only exists when status is success. TypeScript enforces this at compile time -- accessing state.data without narrowing on status is a type error.
XState v5 generates TypeScript types from machine definitions, providing type-safe event sending and state matching. You cannot send an event that the current state does not handle without a compile-time error.
Gotchas
- Over-modeling with state machines adds complexity to simple UI. A toggle button does not need XState --
useState(false)is fine. Use machines when you have 3+ states with conditional transitions. - Guard conditions (conditional transitions) are where business logic lives in a state machine. Missing guards create transitions that should be blocked, like allowing form submission without validation.
- Side effects belong in actions, not in state transitions. XState separates
entryactions (run when entering a state),exitactions (run when leaving), and transition actions. Putting fetch calls in the wrong place causes them to fire at unexpected times. - State machines do not replace all state management. Form field values, scroll positions, and animation progress are continuous data, not discrete states. Use machines for the discrete state transitions and regular state for continuous values.
- Delayed transitions and timeouts (e.g., auto-logout after 15 minutes) must be modeled as machine events, not
setTimeoutside effects. XState'safterproperty handles this declaratively.