State machines turn impossible UI states into unreachable ones
Model a piece of UI with a handful of booleans — isLoading, isError,
isSuccess, isEmpty — and you have quietly created a space of 2^4 = 16 possible
combinations, most of which are nonsense. isLoading && isError is meaningless;
isSuccess && isEmpty && isError is a contradiction. Your code then sprouts
defensive conditionals to paper over states that should never have been
representable in the first place. A state machine fixes this at the root:
instead of independent flags, you have one status that can be exactly one of a
named set of states, with explicit rules for which transitions are legal. The
impossible combinations do not need guarding — they cannot be expressed.
Replace the boolean soup with one status
The refactor is to collapse the flags into a single enum and store the associated data alongside it. Four states, and each carries only what it needs:
// not: { isLoading, isError, isSuccess, data, error } ← 16 combos, most invalid
// but: one status, and data/error that only exist in the right state
const initial = { status: "idle", data: null, error: null };
Now status is the truth, and a component reads it as a single switch — there is
no “what if loading AND error” branch, because the value cannot hold both:
switch (status) {
case "idle": return <Prompt />;
case "loading": return <Spinner />;
case "success": return <List items={data} />;
case "error": return <Error message={error} onRetry={retry} />;
}
Make the transitions explicit and total
The second half of a machine is naming the legal transitions — which events move which state to which. A transition function that only knows the allowed moves makes an illegal one a no-op instead of a corrupt state:
function transition(state, event) {
switch (state.status) {
case "idle": return event === "FETCH" ? { ...state, status: "loading" } : state;
case "loading":
if (event === "OK") return { ...state, status: "success" };
if (event === "FAIL") return { ...state, status: "error" };
return state; // any other event: ignored, not a crash
// success / error transition back to loading on a retry event
default: return state;
}
}
An OK arriving while idle does nothing, because there is no such edge on the
diagram — the race that would corrupt a boolean model simply has nowhere to land.
Where machines earn their keep
Not every widget needs a formal machine, but the pattern pays off exactly where
booleans multiply and race: async data (the request lifecycle), multi-step flows
(a checkout, a wizard), anything with modes (a media player: idle/playing/paused/
buffering), and toggles that must coordinate. For the simplest cases a hand-rolled
status enum and a switch is plenty; for genuinely complex charts of states,
nested and parallel, a library like XState gives you the same guarantees with
tooling and visualisation. Either way the win is the same: you enumerate the states
that should exist, and everything else becomes unrepresentable rather than merely
discouraged. The toast-notifications and tabs-molecule exercises are small machines
in disguise — a set of modes and the legal moves between them — and building them
this way is the fastest cure for boolean soup.