renderpx

State Machines

When boolean flags lie, what breaks in the UI, and how discriminated unions fix it

The Problem

Most async state starts with three boolean flags. This feels natural — each flag answers one question — but it creates bugs that are hard to reproduce and harder to explain.

tsx
Loading...

Bug 1: The eternal spinner

The .catch block sets isError = true but never resets isLoading. The component renders <Spinner /> forever — not because the request is running, but because no one flipped the flag back. This is a real bug that ships to production because it requires a network error in testing to trigger.

Bug 2: Stale data during re-fetch

The user navigates from /profile/1 to /profile/2. setIsLoading(true) fires — but data and isError are independent variables. They are not reset.

tsx
Loading...

Note: a related but distinct problem is the race condition — where an old slow request resolves after a new fast one and overwrites the correct data. That bug is not about state shape; it requires AbortController or a cleanup flag to fix. useEffect and async cleanup →

These bugs share a root cause: three independent boolean flags can be in 8 combinations (2³), but only 4 of those represent valid states. The other 4 are impossible states that exist only in code — and they produce impossible UI: a spinner with loaded data behind it, an error banner that won't clear, stale content from a previous request.

The core insight

The bug isn't that a developer forgot to call setIsLoading(false). The bug is that the language allows it. If the state model makes it impossible to be in both loading and error at the same time, the bug can't exist.

The Fix: Discriminated Unions

A TypeScript discriminated union collapses 8 combinations into exactly 4 valid states — enforced at compile time, at zero runtime cost.

tsx
Loading...

Bug 1 is gone: the eternal spinner

setState({ status: 'error', message }) is a single call that transitions the entire state. There is no isLoading flag to forget. The transition is atomic — either you're in 'error' or you're not.

Bug 2 is gone: the re-fetch overlap

When userId changes, the first call is setState({ status: 'loading' }). This replaces the entire previous state — including any stale data from the previous user. There is no stale data floating independently in data: user1 while isLoading is true.

No library needed

A discriminated union is just a TypeScript type. No dependencies, no runtime overhead, no new mental model beyond useState. This is the right default for any component that models an async operation. React Query uses exactly this shape internally — status: 'pending' | 'success' | 'error' — which is why you rarely need to build it yourself when using a data fetching library.

A discriminated union works well up to about 4–6 states. When transitions between states become conditional — when a step can go to different next states depending on context — a union stops being enough.

The Middle Ground: useReducer

A discriminated union with useState works well for 2–4 states with straightforward transitions. When transitions become conditional — the next state depends on the current state and the event — transition logic starts spreading across multiple handlers. useReducer pulls all of that into one place.

The reducer is just the transition table made explicit. Instead of setState({ status: 'loading' }) scattered across handlers, handlers dispatch events and the reducer decides what the next state is.

5-step checkout with useReducertsx
Loading...

What you gain over useState

  • All transition logic in one place — the reducer is the transition table, readable in one pass
  • Invalid transitions return state unchanged — jumping from email to success is silently ignored
  • Event handlers become thin — dispatch an event, let the reducer decide what happens next
  • Testable in isolation — the reducer is a pure function, no component mounting required

Where useReducer reaches its limit

  • Delayed transitions: a 5-second redirect after success is a separate useEffect — not co-located with the transition that triggers it
  • Parallel states: if the form also needs to track an auto-saving indicator running independently of the checkout step, that becomes a second reducer or extra flags
  • Visualizability: the reducer is the transition table, but you can't auto-generate a diagram from it — at 8+ states, reading it requires building the mental model from scratch

useReducer is usually enough

Most multi-step flows in production — checkout flows, onboarding wizards, multi-step forms with conditional paths — are well-served by a discriminated union + useReducer. The move to XState is warranted when delayed transitions or parallel states make the reducer unwieldy, or when the team needs to reason about the flow without reading code.

When useReducer Isn't Enough: XState

Take the checkout flow from the previous section and add the requirements that real products accumulate:

  • Logged-in users skip the email step
  • Digital products skip the shipping step
  • A payment error sends the user back to the payment step with an error message
  • Address validation failure keeps the user on shipping with field-level errors
  • After 5 seconds on the success screen, auto-redirect to home

Encoding this with a discriminated union and useReducer is possible, but the reducer becomes a dense block of conditional logic. It's hard to read which transitions are valid and which aren't. Compare the two approaches:

tsx
Loading...

XState makes the transitions explicit and co-located. The machine is the documentation:

tsx
Loading...

The cost

XState's API surface is substantial: actors, guards, actions, delays, parallel states. A new developer joining your codebase will spend real time learning the library before they can contribute. That cost is worth it when the state is genuinely complex — when you could draw a transition diagram on a whiteboard and still need the diagram to understand the code. It is not worth it for a simple async fetch or a login form.

The Forms Trap: XState Is Overkill for Form State

A login form has states: pristine → touched → validating → submitting → success | error. That looks like a state machine problem. It isn't — not one you need XState to solve.

React Hook Form already models this lifecycle internally. It tracks touched, dirty, validating, and submitting state; handles async validation and submission; exposes error state per field. The library is the state machine.

tsx
Loading...

The heuristic

If the problem is "validating user input and submitting to an API," use React Hook Form. If the problem is "a multi-step workflow where the path forward depends on previous steps or external events," use a discriminated union first — and XState when the discriminated union grows past ~6 states or needs conditional transitions.

Decision

SituationReach for
Single async operation (loading / success / error)Discriminated union — or just React Query, which models this for you
Form with validation and submissionReact Hook Form
3–5 named states with simple linear transitionsDiscriminated union + useState or useReducer
States with conditional transitions (different next state depending on context)Discriminated union + useReducer with guards — or XState if transitions are > 6
Multi-step wizard with branching pathsXState — draw the transition diagram first, then encode it
Connection lifecycle (WebSocket, SSE) with retries and backoffXState — delayed transitions and guards are built-in
10+ states, nested states, or parallel statesXState — the ceremony is worth it at this complexity

Start here

Default to a discriminated union. It costs nothing — no library, no new mental model, just a TypeScript type and a switch statement. If you find yourself adding guards ("go to X, but only if Y") or delayed transitions ("after 5 seconds, do Z"), that's the signal to reach for XState. The diagram is the machine; the code is just an encoding of it.

Related