Frontend State Machine Patterns
Model complex UI state with finite state machines. Covers XState fundamentals, statechart theory, managing multi-step forms, modal flows, and the patterns that eliminate impossible states by making valid transitions explicit.
Complex UI state is the root cause of most frontend bugs. A modal that can be open and loading and errored simultaneously. A form wizard where the user can somehow reach step 3 without completing step 1. A video player that is playing and paused at the same time. State machines eliminate these impossible states by explicitly defining which states exist and which transitions are allowed.
State Machine Fundamentals
Boolean state explosion:
Traditional approach (4 booleans = 16 possible states):
isLoading: true/false
isError: true/false
isSuccess: true/false
isEmpty: true/false
Possible states: 2⁴ = 16
Valid states: 4
Impossible states: 12 (isLoading AND isError AND isSuccess = ???)
This is the source of 80% of UI bugs.
State machine approach (1 enum = 4 explicit states):
state: "idle" | "loading" | "success" | "error"
Possible states: 4
Valid states: 4
Impossible states: 0
Transitions:
idle → loading (user clicks submit)
loading → success (data received)
loading → error (request failed)
error → loading (user retries)
success → idle (user starts over)
NOT ALLOWED:
idle → success (cannot succeed without loading)
error → success (cannot succeed without retrying)
success → error (cannot fail after succeeding)
XState Implementation
import { createMachine, assign } from 'xstate';
// Multi-step form wizard as a state machine
const formWizardMachine = createMachine({
id: 'formWizard',
initial: 'personalInfo',
context: {
personalInfo: {},
address: {},
payment: {},
errors: [],
},
states: {
personalInfo: {
on: {
NEXT: {
target: 'address',
guard: 'isPersonalInfoValid',
actions: 'savePersonalInfo',
},
},
},
address: {
on: {
NEXT: {
target: 'payment',
guard: 'isAddressValid',
actions: 'saveAddress',
},
BACK: 'personalInfo',
},
},
payment: {
on: {
SUBMIT: {
target: 'submitting',
guard: 'isPaymentValid',
actions: 'savePayment',
},
BACK: 'address',
},
},
submitting: {
invoke: {
src: 'submitForm',
onDone: 'success',
onError: {
target: 'error',
actions: assign({
errors: (_, event) => [event.data.message],
}),
},
},
},
success: {
type: 'final',
},
error: {
on: {
RETRY: 'submitting',
EDIT: 'personalInfo',
},
},
},
});
// Usage in React
function FormWizard() {
const [state, send] = useMachine(formWizardMachine);
return (
<div>
{state.matches('personalInfo') && (
<PersonalInfoStep onNext={() => send('NEXT')} />
)}
{state.matches('address') && (
<AddressStep
onNext={() => send('NEXT')}
onBack={() => send('BACK')}
/>
)}
{state.matches('submitting') && <LoadingSpinner />}
{state.matches('success') && <SuccessMessage />}
{state.matches('error') && (
<ErrorMessage
errors={state.context.errors}
onRetry={() => send('RETRY')}
/>
)}
</div>
);
}
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Boolean state flags | Impossible states, race conditions | State machines with explicit states |
| State machine for simple toggle | Over-engineering | Use state machines for 3+ states with complex transitions |
| Missing transition guards | Invalid state changes possible | Guards validate preconditions for each transition |
| State machine without visualization | Hard to understand complex flows | Use XState visualizer to generate state diagrams |
| Business logic in components | Logic duplicated, hard to test | Business logic in machine, components are pure renderers |
State machines are not about replacing all state management — they are about protecting the most complex state transitions from impossible combinations. Any UI flow with more than two states, conditional transitions, and error handling benefits from a state machine.