Lesson 10 of 26
State Machines for Complex UI
Why a handful of booleans falls apart for multi-step flows, and how modeling explicit states and transitions rules out impossible combinations by construction.
Most UI state starts simple: a boolean here, a boolean there. It's fine for a toggle. It stops being fine the moment a flow has more than two or three steps, or more than a couple of flags that interact — because independent booleans describe a set of switches, not a set of valid states, and the gap between those two things is where an entire category of UI bugs lives.
The boolean-soup problem
A common, informal shape for a data-fetching or upload flow looks like this:
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);Nothing in this shape prevents isLoading: true and isSuccess: true from
being true at the same moment — a state that should be logically
impossible (you can't simultaneously still be loading and have already
succeeded), but is fully representable, because these are three independent
booleans, not one description of "what state is this in right now." Every
component reading this state has to defensively guard against combinations
that should never occur, and every place that sets this state has to
remember to reset the other flags correctly — miss one setIsError(false)
on retry, and you can end up showing both an old error and a fresh success
message at once.
The state machine alternative
A state machine replaces several independent flags with one field that can only ever hold one value from a known, finite set:
// status: "idle" | "uploading" | "success" | "error"
const [status, setStatus] = useState("idle");- Step 1
idle
Nothing has happened yet — the initial state.
- Step 2
uploading
Entered on an UPLOAD_STARTED event. The only states reachable from here are success or error.
- Step 3
success
Entered on an UPLOAD_SUCCEEDED event. A RESET event returns to idle.
- Step 4
error
Entered on an UPLOAD_FAILED event. A RETRY event returns to uploading; a RESET returns to idle.
Two things change here that are worth naming precisely. First,
impossible combinations become unrepresentable, not just
defended-against — you cannot be uploading and success at the same time,
because there's a single field holding a single value. Second, transitions
are explicit and named (UPLOAD_STARTED, UPLOAD_FAILED, RETRY) rather
than several booleans flipped, in slightly different order, by different
code paths that each have to remember to reset the others correctly.
Where this earns its complexity — and where it doesn't
A single loading spinner with one boolean doesn't need this — the ceremony would outweigh the benefit. State machines pay off precisely where flows have several steps, several ways to fail, and rules about which transitions are even legal: a multi-step checkout (can't jump from cart straight to confirmation), a video call's connection lifecycle (connecting, connected, reconnecting, failed), an upload-with-retry flow, a wizard with conditional branches. In all of these, the state machine centralizes "what transitions are allowed from here" in one place — a transition table — instead of that rule being re-derived, possibly inconsistently, in every button handler and effect that might trigger a step change.
Libraries like XState formalize this further with hierarchical and parallel
states, guards, and side-effect actions attached to transitions — genuinely
useful once a flow's complexity grows past what a hand-rolled reducer
comfortably expresses. But you don't need a library to get the core benefit:
even a plain useReducer with a typed status union and an explicit switch
over named actions captures most of the value. The interview-relevant
insight isn't "know XState's API," it's recognizing when a flow has outgrown
independent booleans and needs an explicit, exhaustive model of its valid
states and legal transitions between them.
What to remember
- Independent boolean flags can represent combinations that should be logically impossible, and every consumer has to defensively guard against that.
- A state machine replaces those flags with one field holding one value from a known set, making invalid combinations unrepresentable rather than merely avoided.
- Explicit, named transitions centralize "what's allowed to happen next" in one place, instead of that rule being scattered and re-derived across the codebase.
- Reach for this once a flow has several steps or failure modes with real ordering rules — not for a single spinner, where it's unnecessary ceremony.
Check yourself
3 questions · pass 3/3 to unlock The Critical Rendering Path
1.A file upload component tracks
isUploading,isError, andisSuccessas three separate boolean flags. What's the concrete problem with this shape as the flow gets more complex?2.What does modeling a flow as an explicit state machine (e.g.
idle | uploading | success | error) actually buy you, compared to several booleans?3.A checkout flow has steps like cart -> shipping -> payment -> confirmation, with the ability to go back a step but not skip ahead. Why does a state machine fit this better than a single
currentStepnumber plus a scattering of conditional checks?
3 left to answer