Most UI bugs in the "impossible" category are the same bug: two booleans that should never both be true. isLoading and isError both set. A form that is submitting and editable at once. A modal that is closed but still holding data.
A state machine removes that category of bug by construction — not by being more careful, but by making the invalid combinations unrepresentable. This guide covers when that is worth doing and how it looks in XState 5.32 with @xstate/react 6.1.
The problem with boolean state
Here is state most React codebases contain somewhere:
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [data, setData] = useState(null)
Four booleans is sixteen possible combinations. Perhaps four are valid. The other twelve are bugs waiting for the right race condition — a user clicking retry while a request is in flight, a response arriving after the component moved on.
You cannot test your way out of this reliably, because the invalid states are reachable by definition. The fix is to make them unreachable.
The same thing as a machine
import { setup, assign, fromPromise } from 'xstate'
export const dataMachine = setup({
types: {
context: {} as { data: User[] | null; error: string | null },
events: {} as { type: 'FETCH' } | { type: 'RETRY' } | { type: 'CANCEL' },
},
actors: {
fetchUsers: fromPromise(async () => {
const res = await fetch('/api/users')
if (!res.ok) throw new Error('Request failed')
return res.json()
}),
},
}).createMachine({
id: 'data',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: {
on: { FETCH: 'loading' },
},
loading: {
invoke: {
src: 'fetchUsers',
onDone: {
target: 'success',
actions: assign({ data: ({ event }) => event.output, error: null }),
},
onError: {
target: 'failure',
actions: assign({ error: ({ event }) => String(event.error) }),
},
},
on: { CANCEL: 'idle' },
},
success: {
on: { FETCH: 'loading' },
},
failure: {
on: { RETRY: 'loading' },
},
},
})
Four states, and only four. There is no way to be loading and failed simultaneously, because a machine is in exactly one state at a time. The twelve invalid combinations do not exist to be tested.
Notice something else: RETRY is only accepted in failure. Send it while loading and nothing happens — no guard clause, no early return. The machine simply has no transition for it.
What changed in XState 5
If you are reading older tutorials, the syntax will not match. The main differences:
| XState 4 | XState 5 | |
|---|---|---|
| Setup | createMachine + options object | setup({...}).createMachine({...}) |
| Types | Generics, often awkward | Declared in setup.types |
| Services | services | actors |
| Action arguments | (context, event) | ({ context, event }) |
| Promises | invoke with a promise | fromPromise() |
| React hook | useMachine | useMachine, actor-based |
The practical upshot is that TypeScript inference is dramatically better. Declaring your events in setup gives you exhaustive checking — send an event the machine does not handle and it is a compile error, not a silent no-op.
Using it in React
import { useMachine } from '@xstate/react'
import { dataMachine } from './dataMachine'
export function UserList() {
const [state, send] = useMachine(dataMachine)
if (state.matches('idle')) {
return <button onClick={() => send({ type: 'FETCH' })}>Load users</button>
}
if (state.matches('loading')) {
return (
<>
<Spinner />
<button onClick={() => send({ type: 'CANCEL' })}>Cancel</button>
</>
)
}
if (state.matches('failure')) {
return (
<div role="alert">
<p>{state.context.error}</p>
<button onClick={() => send({ type: 'RETRY' })}>Try again</button>
</div>
)
}
return <ul>{state.context.data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
The component became a rendering function of one value. There is no combination of flags to reason about, and adding a state later means adding a branch the compiler will point at.
Guards and context
Real flows need conditions. Guards decide whether a transition is allowed:
const checkoutMachine = setup({
types: {
context: {} as { items: Item[]; attempts: number },
events: {} as { type: 'SUBMIT' } | { type: 'RETRY' },
},
guards: {
hasItems: ({ context }) => context.items.length > 0,
underRetryLimit: ({ context }) => context.attempts < 3,
},
}).createMachine({
initial: 'cart',
context: { items: [], attempts: 0 },
states: {
cart: {
on: {
SUBMIT: { target: 'paying', guard: 'hasItems' },
},
},
paying: {
on: {
RETRY: [
{ target: 'paying', guard: 'underRetryLimit',
actions: assign({ attempts: ({ context }) => context.attempts + 1 }) },
{ target: 'failed' },
],
},
},
failed: { type: 'final' },
},
})
That retry array is worth reading closely: transitions are evaluated in order, and the first whose guard passes wins. Under three attempts it retries; otherwise it falls through to failed. The retry limit is part of the machine definition rather than an if buried in a handler.
Testing
Machines are unusually pleasant to test, because you can drive them without rendering anything:
import { createActor } from 'xstate'
import { describe, it, expect } from 'vitest'
import { dataMachine } from './dataMachine'
describe('dataMachine', () => {
it('ignores RETRY while loading', () => {
const actor = createActor(dataMachine).start()
actor.send({ type: 'FETCH' })
expect(actor.getSnapshot().value).toBe('loading')
actor.send({ type: 'RETRY' })
expect(actor.getSnapshot().value).toBe('loading') // unchanged
})
it('can only retry from failure', () => {
const actor = createActor(dataMachine).start()
expect(actor.getSnapshot().can({ type: 'RETRY' })).toBe(false)
})
})
can() is the useful one. It answers "is this event valid right now?" without side effects — handy both in tests and for disabling buttons in the UI from the same source of truth.
When not to use a state machine
This is the part most articles leave out, and it matters more than the API.
- Boolean toggles. A dropdown that is open or closed is a
useState. Wrapping it in a machine is ceremony. - Server state. Caching, revalidation and deduplication are what TanStack Query exists for. Do not rebuild it as a machine.
- Simple forms. React Hook Form covers validation and submission well. Reach for a machine when the form is a multi-step flow with branching.
- When the team will not maintain it. A machine nobody else understands is worse than flags everybody does. Introduce it on one complex flow and see how the team finds it.
The honest test: count the invalid combinations your current state allows. If the answer is zero or one, you do not need a machine. If it is a dozen, you already have a machine — an implicit one, spread across handlers.
Where machines earn their keep
Multi-step checkout and onboarding. Media players with buffering, seeking and error recovery. Anything with retries and cancellation. Drag-and-drop. Authentication flows with 2FA challenges and recovery paths. Long-running processes with progress and rollback.
The pattern is the same across all of them: several genuinely distinct modes, transitions that are only valid from certain modes, and a real cost when an invalid combination reaches a user.
Frequently asked questions
When should I use a state machine instead of useState?
When your component has several genuinely distinct modes and some transitions are only valid from some of them. A useful test: count the combinations your booleans allow that should never happen. Zero or one means useState is fine; a dozen means you already have an implicit machine.
What changed between XState 4 and XState 5?
Machines are now defined with setup({...}).createMachine({...}), types are declared in setup.types rather than through generics, services became actors, promises use fromPromise(), and action arguments are a single object. TypeScript inference is much stronger as a result.
Is XState overkill for a small app?
Often, yes. It earns its cost on flows with several modes and invalid transitions — checkout, onboarding, media playback, retry and cancellation. For toggles and simple forms it is ceremony without benefit.
Does XState replace Redux or Zustand?
They solve different problems. Redux and Zustand hold shared application state; XState models how one thing moves between modes. Many applications use both — a store for global data, machines for complex flows.
Should I use XState for data fetching?
Usually not on its own. TanStack Query already handles caching, revalidation and deduplication. Use a machine when the fetch is part of a larger flow with cancellation, retry limits or branching afterwards.
How do I test an XState machine?
Create an actor with createActor(), send events, and assert on getSnapshot().value and context. No rendering required, so the tests are fast. can() lets you assert that an invalid event is genuinely rejected.
How do I use XState with React?
Install @xstate/react and call useMachine(machine), which returns the current state and a send function. Branch your render on state.matches(...) and read data from state.context.
Are state machines and statecharts the same thing?
A statechart is a finite state machine plus hierarchy, parallel states and history. XState implements statecharts, so you can start with flat states and add nesting only when a flow genuinely needs it.
Comments (0)
Leave a Comment
No comments yet. Be the first to share your thoughts!