State Machines with XState
The Boolean Explosion Problem
You're building an authentication flow. You start simple:
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
Three booleans, eight possible combinations. But only four make sense:
| isLoading | isError | isAuthenticated | Valid? |
|---|---|---|---|
| false | false | false | Idle |
| true | false | false | Loading |
| false | true | false | Error |
| false | false | true | Authenticated |
| true | true | false | Loading AND error? |
| true | false | true | Loading AND authenticated? |
| false | true | true | Error AND authenticated? |
| true | true | true | All three at once? |
Half of the combinations are impossible states — but your type system allows them. And eventually, a race condition or a forgotten setIsLoading(false) puts your app in one of those impossible states. Users see a loading spinner AND an error message. Or they're "authenticated" but the loading indicator never disappears.
State machines make impossible states impossible by design.
A state machine is a directed graph where nodes are states and edges are transitions triggered by events. At any moment, the machine is in exactly ONE state. From that state, only specific events are valid — everything else is ignored. You can't be "loading" and "error" simultaneously because they're separate nodes, not separate booleans. It's like a traffic light: it's red OR yellow OR green, never two at once. The transitions (red to green, green to yellow, yellow to red) are explicit and exhaustive.
XState v5: The Basics
XState v5 is a complete rewrite focused on the actor model. Let's start with the fundamentals:
import { createMachine, createActor } from 'xstate';
const authMachine = createMachine({
id: 'auth',
initial: 'idle',
context: {
user: null as User | null,
error: null as string | null,
},
states: {
idle: {
on: {
LOGIN: 'authenticating',
},
},
authenticating: {
invoke: {
src: 'loginService',
input: ({ event }) => ({
email: event.email,
password: event.password,
}),
onDone: {
target: 'authenticated',
actions: ({ context, event }) => ({
...context,
user: event.output,
error: null,
}),
},
onError: {
target: 'error',
actions: ({ context, event }) => ({
...context,
error: event.error.message,
}),
},
},
},
authenticated: {
on: {
LOGOUT: 'idle',
},
},
error: {
on: {
RETRY: 'authenticating',
RESET: 'idle',
},
},
},
});
This machine has four states. From idle, the only valid event is LOGIN. You literally cannot trigger a logout from the idle state — the machine ignores it. From authenticating, nothing external can happen — the machine waits for the async service to resolve or reject. This is not just documentation; it's enforced logic.
The setup() Function
XState v5 introduces setup() for defining types, actions, guards, and services before creating the machine:
import { setup, assign } from 'xstate';
const checkoutMachine = setup({
types: {
context: {} as {
items: CartItem[];
shippingAddress: Address | null;
paymentMethod: PaymentMethod | null;
error: string | null;
},
events: {} as
| { type: 'PROCEED' }
| { type: 'BACK' }
| { type: 'SET_ADDRESS'; address: Address }
| { type: 'SET_PAYMENT'; method: PaymentMethod }
| { type: 'CONFIRM' },
},
guards: {
hasItems: ({ context }) => context.items.length > 0,
hasAddress: ({ context }) => context.shippingAddress !== null,
hasPayment: ({ context }) => context.paymentMethod !== null,
},
actions: {
setAddress: assign({
shippingAddress: ({ event }) => {
if (event.type !== 'SET_ADDRESS') return null;
return event.address;
},
}),
setPayment: assign({
paymentMethod: ({ event }) => {
if (event.type !== 'SET_PAYMENT') return null;
return event.method;
},
}),
clearError: assign({ error: null }),
},
}).createMachine({
id: 'checkout',
initial: 'cart',
context: {
items: [],
shippingAddress: null,
paymentMethod: null,
error: null,
},
states: {
cart: {
on: {
PROCEED: {
target: 'shipping',
guard: 'hasItems',
},
},
},
shipping: {
on: {
SET_ADDRESS: { actions: 'setAddress' },
PROCEED: { target: 'payment', guard: 'hasAddress' },
BACK: 'cart',
},
},
payment: {
on: {
SET_PAYMENT: { actions: 'setPayment' },
CONFIRM: { target: 'processing', guard: 'hasPayment' },
BACK: 'shipping',
},
},
processing: {
invoke: {
src: 'processOrder',
onDone: 'confirmation',
onError: {
target: 'payment',
actions: assign({
error: ({ event }) => event.error.message,
}),
},
},
},
confirmation: {
type: 'final',
},
},
});
The setup() function gives you full TypeScript inference for events, context, guards, and actions. No more as any or manual type assertions.
Guards: Conditional Transitions
Guards prevent transitions unless a condition is met:
states: {
cart: {
on: {
PROCEED: {
target: 'shipping',
guard: 'hasItems',
},
},
},
}
If hasItems returns false, the PROCEED event is ignored. The machine stays in cart. This is how you enforce business rules at the state level — you can't check out with an empty cart, period.
Actors: The XState v5 Core
XState v5 centers on the actor model. Machines are used to create actors — running instances with their own state, behavior, and lifecycle:
import { createActor } from 'xstate';
const checkoutActor = createActor(checkoutMachine, {
input: { items: cartItems },
});
checkoutActor.subscribe((snapshot) => {
console.log('State:', snapshot.value);
console.log('Context:', snapshot.context);
});
checkoutActor.start();
checkoutActor.send({ type: 'PROCEED' });
In React, use the useMachine hook from @xstate/react:
import { useMachine } from '@xstate/react';
function CheckoutFlow() {
const [snapshot, send] = useMachine(checkoutMachine);
switch (snapshot.value) {
case 'cart':
return <CartView items={snapshot.context.items} onProceed={() => send({ type: 'PROCEED' })} />;
case 'shipping':
return <ShippingForm onSubmit={(addr) => send({ type: 'SET_ADDRESS', address: addr })} />;
case 'payment':
return <PaymentForm onSubmit={(method) => send({ type: 'SET_PAYMENT', method })} />;
case 'processing':
return <ProcessingSpinner />;
case 'confirmation':
return <OrderConfirmation />;
default:
return null;
}
}
The switch over snapshot.value is exhaustive — TypeScript ensures you handle every state. Miss one and you get a compile error.
When State Machines Solve Real Problems
State machines add upfront design cost. They're overkill for a sidebar toggle. But they pay for themselves in specific scenarios:
Complex UI Flows with Strict Ordering
Multi-step wizards, onboarding flows, checkout processes — anywhere the user must follow a specific sequence with conditional branching and back-tracking.
Async Processes with Error Recovery
Payment processing, file uploads, API orchestration — anywhere you need clear handling of pending, success, failure, retry, and cancellation states.
Impossible State Prevention
Any feature where the combination of boolean flags creates invalid states. If you've ever debugged a "loading AND error simultaneously" bug, that's a state machine screaming to be born.
The ad-hoc boolean approach and its failure modes
Consider a file upload component with these states: idle, selecting, uploading, processing, success, error. With booleans:
const [isSelecting, setIsSelecting] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);Five booleans = 32 possible combinations. Only 6 are valid. You now need to ensure that every state transition correctly sets/unsets all 5 booleans. Miss one setIsUploading(false) and you have a phantom loading state. With a state machine, the machine is in exactly one of 6 states. Period.
The Visual Editor
One of XState's unique strengths is the Stately visual editor at stately.ai. You can:
- Design machines visually by dragging states and drawing transitions
- Simulate machines — click through states to verify behavior
- Export to code — the visual design generates XState v5 code
- Import from code — paste your machine and see the visual representation
This visual aspect is invaluable for communicating complex flows with designers, product managers, and QA. The state diagram IS the spec — and it's executable.
Comparing with Ad-Hoc Boolean State
| Aspect | Boolean Flags | XState State Machine |
|---|---|---|
| State representation | N booleans = 2^N possible combinations | N explicit states, all valid |
| Impossible states | Possible — must be prevented manually | Impossible by design |
| Transitions | Implicit — any setState call from anywhere | Explicit — only defined transitions are valid |
| Visualization | None — logic scattered across handlers | Visual state diagram, auto-generated or designed |
| TypeScript support | Boolean checks, no exhaustiveness | Discriminated unions, exhaustive matching |
| Async processes | Manual isLoading/isError management | invoke with built-in onDone/onError |
| Debugging | Console.log scattered everywhere | State inspection, event history, visual simulation |
| Best for | Simple toggles, 1-2 states | 4+ states with complex transitions |
| What developers do | What they should do |
|---|---|
| Using XState for every piece of state in the application State machines add upfront design cost. For a boolean toggle or a simple counter, that cost exceeds the benefit. Reserve XState for flows where impossible states, strict ordering, or async orchestration matter. | Use XState only for complex flows with multiple states, transitions, and guards. Use useState/Zustand for simple state. |
| Using XState v4 API (Machine, interpret) in new projects XState v5 is a complete rewrite with better TypeScript support, the actor model, and the setup() function for type-safe machine definitions. v4 patterns don't translate directly. | Use XState v5 API: setup(), createMachine, createActor |
| Putting server-fetched data in XState context instead of using TanStack Query XState has no caching, deduplication, or background refetching. Use it for orchestrating flows, not for caching API responses. | XState manages flow logic (which step, what transitions are valid). Server data lives in TanStack Query. |
- 1State machines make impossible states impossible by design. Use them when boolean combinations create invalid states.
- 2XState v5 uses the actor model: createMachine defines logic, createActor creates a running instance.
- 3setup() provides full TypeScript inference for events, context, guards, and actions. Always use it in v5.
- 4Guards prevent transitions conditionally — they enforce business rules at the state level.
- 5Use XState for complex flows (4+ states, async, conditional branching). Use useState for simple toggles.
- 6The visual editor at stately.ai turns state machines into living documentation — share with designers and PMs.