Skip to the content.

State machines turn impossible UI states into unreachable ones

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.

A single status enum with legal transitions replaces a set of independent booleans Four named states — idle, loading, success, error — with arrows for the only legal transitions between them. A dot travels the happy path from idle through loading to success. idle loading success error FETCHOKFAIL
One status, four legal states, four legal transitions. "loading and error at once" is not on the diagram, so it cannot happen.

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.