A21 · Part 3 · Advanced
State machines and XState
When a screen has modes, list the modes and the events each one accepts, instead of guessing from booleans.
09On this page
The bug a machine prevents
const [isLoading, setLoading] = useState(false);
const [isError, setError] = useState(false);
const [isSuccess, setSuccess] = useState(false);
const [isPaused, setPaused] = useState(false);
Four booleans give sixteen combinations and the component handles perhaps five. The other eleven — loading and success, paused while in error — are reachable, unhandled, and found by users. A finite state machine replaces the flags with one value that is exactly one of a fixed list, plus a rule for which events move it where. If an event is not listed for the current state, nothing happens. The reducer chapter made this argument for one field; a machine is that idea made complete.
Start without a library
const machine = {
idle: { FETCH: "loading" },
loading: { RESOLVE: "success", REJECT: "error" },
success: { FETCH: "loading" },
error: { RETRY: "loading" },
};
function transition(state, event) {
return machine[state]?.[event] ?? state; // unknown event: stay put
}
const [state, dispatch] = useReducer(transition, "idle");
dispatch("FETCH");
That table is a complete machine. Read it as documentation: from
loading you can only resolve or reject, so a second click on a
"Fetch" button while loading is ignored by construction, not by an
if (isLoading) return someone must remember. The same table is a
type, too — the discriminated union
from the TypeScript chapter is the compile-time half of the same idea.
XState v5
import { setup, assign, fromPromise } from "xstate";
import { useMachine } from "@xstate/react";
const userMachine = setup({
types: {
context: {} as { id: string; user: User | null; error: string | null },
events: {} as { type: "FETCH"; id: string } | { type: "RETRY" },
},
actors: {
loadUser: fromPromise(async ({ input }: { input: { id: string } }) => getUser(input.id)),
},
}).createMachine({
id: "user",
initial: "idle",
context: { id: "", user: null, error: null },
states: {
idle: {
on: { FETCH: { target: "loading", actions: assign({ id: ({ event }) => event.id }) } },
},
loading: {
invoke: {
src: "loadUser",
input: ({ context }) => ({ id: context.id }),
onDone: { target: "success", actions: assign({ user: ({ event }) => event.output }) },
onError: { target: "error", actions: assign({ error: ({ event }) => String(event.error) }) },
},
},
success: { on: { FETCH: { target: "loading" } } },
error: { on: { RETRY: "loading" } },
},
});
function Profile() {
const [snapshot, send] = useMachine(userMachine);
if (snapshot.matches("loading")) return <Spinner />;
if (snapshot.matches("error")) return <button onClick={() => send({ type: "RETRY" })}>Retry</button>;
if (snapshot.matches("success")) return <p>{snapshot.context.user.name}</p>;
return <button onClick={() => send({ type: "FETCH", id: "1" })}>Load</button>;
}
setup() declares the types and the named implementations first,
so the machine definition stays readable and the events, context and actors are
checked. invoke starts an actor when the state is entered and
stops it when the state is left — leaving loading cancels
the request's result, which is the race condition from
data fetching solved structurally.
Context holds the data that travels with the machine; state
holds the mode. Keep them separate: "is a user loaded" is a state, "who" is
context.
The parts a reducer does not give you
loading: {
after: { 8000: "timeout" }, // delayed transition
on: {
CANCEL: "idle",
RESOLVE: { guard: ({ context }) => context.attempts < 3, target: "success" },
},
}
- Guards: a transition that only applies if a condition holds, declared beside the transition instead of buried in a handler.
- Delayed transitions:
afterhandles timeouts, debounces and auto-dismiss without a singlesetTimeoutor cleanup to forget. - Hierarchical states: a
submittingstate can have its ownvalidatingandsendingchildren, and an event handled on the parent applies to all of them. - Parallel states: a media player is playing and muted and fullscreen; each dimension is its own region rather than an explosion of combined names.
- Reading it:
snapshot.matches("loading")for a state, andsnapshot.can({ type: "RETRY" })to ask whether an event would do anything — ideal for disabling a button truthfully.
Where side effects go
states: {
saving: {
entry: "showSpinner", // runs on the way in
exit: "hideSpinner", // runs on the way out, however it leaves
invoke: { src: "save", onDone: "saved", onError: "failed" },
},
}
The rule that keeps a machine testable: transitions and guards are pure,
and effects live in three named places. entry and
exit actions fire when a state is entered or left — and
exit fires however the state is left, which makes it the reliable
spot for cleanup. Actions on a transition run as it happens. invoke
starts long-running work tied to the state's lifetime. Changing context always
goes through assign, never a mutation, so every change is visible
in the definition. Because the actions are named in the machine and
implemented in setup() (or supplied later with
machine.provide()), a test can swap the real network call for a
fake without touching the logic.
Mistakes worth avoiding
- States named after events. A state is a mode you can be in —
saving, notonSaveClicked. If the name is a verb in the past tense, it is probably an event. - Data smuggled into states.
loadedUser1,loadedUser2is context wearing a state's clothes. Ten states that differ by one value are one state and one field. - Duplicating what React already tracks. Text inside an input, a scroll position and anything derivable from props do not belong in a machine.
- One giant machine. Split by responsibility — a checkout flow, a payment form, an address form — and let a parent machine start the others as actors and hear back from them.
- Reaching for it first. Draw the states on paper. If there are two of them, write the
useState.
Using it across components
const CheckoutContext = createActorContext(checkoutMachine);
<CheckoutContext.Provider><Steps /></CheckoutContext.Provider>
const step = CheckoutContext.useSelector((s) => s.value);
const actor = CheckoutContext.useActorRef(); // stable; does not re-render on change
useMachine creates the machine for one component's lifetime. When a
wizard, a player or an upload queue must be shared, createActorContext
provides the running actor and useSelector subscribes to just a
slice — the same selector idea as in
state libraries, and for the same
reason. To restore a saved session, pass a persisted snapshot
option when creating the actor.
Where it earns its place, and where it does not
| A machine fits | A plain useState is enough |
|---|---|
| Multi-step wizards and checkout flows | An open/closed toggle |
| Upload, payment and auth flows with retries and cancel | A controlled input |
| Media players, drag interactions, editors with modes | Data derived from props |
| Anything where "which events are valid now?" is a real question | Two states with one transition |
The test is the question in the last row. If you find yourself writing
if (status === "x") return at the top of handlers to ignore events
that arrived at the wrong time, the states and transitions already exist —
they are just unwritten. That is the moment to write them down. For everything
smaller, the reducer or a union type gives most of the benefit with no
dependency.
Testing gets easier
test("cannot retry from idle", () => {
const actor = createActor(userMachine).start();
actor.send({ type: "RETRY" });
expect(actor.getSnapshot().value).toBe("idle");
});
A machine is a pure description, so you can test the logic without rendering: send events, assert on the state. And because the definition is data, tools can draw it as a diagram — Stately's editor does, and the picture is the quickest way to show a product manager the case nobody specified.
Interview answer, one sentence
"A state machine replaces a pile of booleans with one state from a fixed list and explicit transitions, so impossible combinations cannot be reached and an event in the wrong state is ignored by construction; I would use a reducer or a union type for simple cases and XState when there are timeouts, guards, nesting or several actors involved."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series