AniUI Academy

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.

12 min read

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");
  1. Step 1

    idle

    Nothing has happened yet — the initial state.

  2. Step 2

    uploading

    Entered on an UPLOAD_STARTED event. The only states reachable from here are success or error.

  3. Step 3

    success

    Entered on an UPLOAD_SUCCEEDED event. A RESET event returns to idle.

  4. Step 4

    error

    Entered on an UPLOAD_FAILED event. A RETRY event returns to uploading; a RESET returns to idle.

Being in 'uploading' and 'success' simultaneously isn't defended against — it's simply impossible to represent, because status can only hold one value.

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

up to 50
  1. 1.A file upload component tracks isUploading, isError, and isSuccess as three separate boolean flags. What's the concrete problem with this shape as the flow gets more complex?

  2. 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. 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 currentStep number plus a scattering of conditional checks?

3 left to answer