React State Machines with XState
Learn how XState brings finite state machines to React, eliminating impossible UI states with explicit transitions, guards, and a predictable mental model for complex flows.
What you'll learn
- ✓What a finite state machine is
- ✓Why XState beats boolean flags
- ✓How to wire XState into React
- ✓Guards, actions, and context
- ✓Where state machines shine and where they overkill
Prerequisites
- •Comfortable with React hooks
State machines turn fuzzy UI logic into a small, explicit set of states and transitions. XState is the most popular library for using them in React. This tutorial walks through the mental model, a working example, and the tradeoffs.
What and Why
A finite state machine has a known set of states, a known set of events, and rules that say which events move you between states. There is exactly one current state at any moment. That single property is what makes machines so useful for UI work.
Most React bugs come from boolean soup. You end up with isLoading, isError, hasData, and isRetrying and the combinations that should be impossible end up rendering anyway. A state machine makes impossible states unrepresentable because you can only be in one named state at a time.
XState gives you a declarative way to write that machine, a React hook to wire it up, and a visualizer to inspect it. You stop guessing about UI behavior and start designing it.
Mental Model
Think of a traffic light. It is red, yellow, or green, never two at once. A timer event moves it forward in a fixed cycle. That is a state machine. Your data fetcher is the same shape: idle, loading, success, failure, with events like FETCH, RESOLVE, REJECT.
XState adds two upgrades. Context holds extended data attached to the machine, like the fetched payload. Guards are predicates on transitions, so an event only fires if a condition is met. Actions are side effects triggered when transitions happen.
Hands-on Example
Here is a small fetch machine and its React integration.
FETCH
idle ------> loading
|
RESOLVE | REJECT
v
success / failure
|
RETRY
v
loading import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';
const fetchMachine = createMachine({
id: 'fetch',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: { on: { FETCH: 'loading' } },
loading: {
invoke: {
src: 'fetchUser',
onDone: { target: 'success', actions: assign({ data: (_, e) => e.data }) },
onError: { target: 'failure', actions: assign({ error: (_, e) => e.data }) },
},
},
success: { on: { FETCH: 'loading' } },
failure: { on: { RETRY: 'loading' } },
},
});
function User() {
const [state, send] = useMachine(fetchMachine, {
services: { fetchUser: () => fetch('/api/me').then((r) => r.json()) },
});
if (state.matches('idle')) return <button onClick={() => send('FETCH')}>Load</button>;
if (state.matches('loading')) return <p>Loading...</p>;
if (state.matches('failure')) return <button onClick={() => send('RETRY')}>Retry</button>;
return <pre>{JSON.stringify(state.context.data, null, 2)}</pre>;
}
The rendering is driven by state.matches. There is no way to render a loading spinner next to an error message because the machine cannot be in both states at once.
Common Pitfalls
Putting everything in a machine is the first mistake. Forms with five fields do not need a machine. Reach for XState when transitions matter more than the values.
Overloading context with data that belongs in React Query or props is another trap. The machine should own state shape, not the entire data layer.
Forgetting that useMachine recreates the machine on each call is a subtle issue. Define the machine outside the component or use useMemo so identity stays stable.
Finally, do not skip the visualizer. Drawing the chart is half the value, and you miss it if you only read the JSON.
Best Practices
Keep machines small and composable. If a chart grows past ten states, split it into child machines or use hierarchical states. Name events as verbs and states as nouns to keep reading natural.
Push side effects into actions and services rather than firing them from React. That keeps the machine portable and testable. Write unit tests by sending events and asserting on state, no rendering required.
Use the inspector during development. Watching transitions live catches missing edges faster than reading code.
Wrap-up
XState gives React apps a precise vocabulary for behavior. Once you think in states and events, entire categories of bugs disappear. Start with one tangled component, model it, and ship the machine. The clarity is addictive.
Related articles
- React React Context vs Redux: When to Use Which
A practical comparison of React Context and Redux: rendering model, performance, devtools, and concrete heuristics for picking the right tool.
- React React Error Boundaries: A Practical Guide
How to build resilient React apps with Error Boundaries: what they catch, what they miss, and how to design fallback UI that actually helps users.
- React React Forms: Controlled vs Uncontrolled Inputs Explained
Understand the difference between controlled and uncontrolled inputs in React, when to use each, and how to combine them with refs and form libraries.
- React The React key Prop: A Deep Dive Into Identity and Reconciliation
Understand what the React key prop actually does, how it drives reconciliation, and why a bad key choice causes mysterious state and animation bugs.