A lot of the UI bugs I've fixed weren't in the business logic at all. They came from a component landing in a state nobody thought was possible: a spinner that keeps spinning after the data arrived, an old error message sitting next to fresh results, a submit button that fires twice. The root cause is usually the same — state described as a bag of independent boolean flags. This post is how I switched to thinking in state machines: list the states and events explicitly, and allow only valid transitions.

The "boolean soup" problem#

Look at a data-loading component written the familiar way:

TSX
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<Order[] | null>(null);

async function load() {
  setIsLoading(true);
  try {
    setData(await fetchOrders());
  } catch (e) {
    setIsError(true);
    setError(String(e));
  } finally {
    setIsLoading(false);
  }
}

Looks fine, but count: three boolean-or-nullable flags give at least 2 × 2 × 2 = 8 combinations, while the real UI only has about four meaningful states. The rest are impossible states — yet the code happily allows them:

  • The second load succeeds, but isError from the previous attempt is still true because nobody reset it. The screen shows data and an error.
  • The user hits "Reload" twice in a row; the first response arrives after the second and overwrites newer data.
  • What should isLoading && data render? A spinner or the list? Everyone writing JSX answers differently.

Each new flag doubles the combinations. And every if in the JSX has to implicitly rule out the nonsensical ones — knowledge that only lives in the author's head.

Thinking in state machines: states, events, transitions#

A finite state machine has three parts:

  • States: a finite set, and at any moment you're in exactly one of them.
  • Events: things that happen — a button click, a response arriving, a timer firing.
  • Transitions: rules of the form "in state X, on event Y, go to state Z". An event with no matching transition is ignored.

The key insight: not every event is valid in every state. Clicking "Submit" while already submitting should do nothing. A response arriving after the user cancelled should do nothing. Instead of sprinkling if (isLoading) return everywhere, that rule lives in one place.

For the loading flow above, the transition table looks like this:

Current stateEventNext state
idleFETCHloading
loadingRESOLVEsuccess
loadingREJECTfailure
successREFRESHloading (keep old data on screen)
failureRETRYloading
anyany other eventunchanged

Writing this table down before writing code is the step I find most valuable. It forces me (and the PM, and the designer) to answer questions that usually get skipped: do we show stale data while refreshing? Does the error disappear by itself? Can the user cancel?

You don't need a library to "use a state machine". A discriminated union for the state plus a (state, event) => state function already is one. Libraries help when the machine gets big — more on when at the end.

Modeling state with a discriminated union#

In TypeScript, the natural way to say "exactly one state at a time, each with its own data" is a discriminated union. Data only exists in the states where it makes sense:

TypeScript
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading"; previous: T | null }
  | { status: "success"; data: T }
  | { status: "failure"; error: string; previous: T | null };

Now data next to error simply can't be represented. When rendering, TypeScript forces you to check status before reading data, and a switch with assertNever errors if you forget a state. The previous field keeps old data during a refresh — a UX decision written into the type instead of left implicit.

A typed state machine with useReducer#

A more realistic example: an appointment booking form. Requirements: no double submits, validation errors show immediately, server errors can be retried, after success you can start over, and the user can cancel while the request is in flight.

TSX
import { useReducer } from "react";

type FormValues = { name: string; email: string; slot: string };

type State =
  | { status: "editing"; values: FormValues; fieldErrors: Partial<Record<keyof FormValues, string>> }
  | { status: "submitting"; values: FormValues; requestId: number }
  | { status: "failed"; values: FormValues; message: string }
  | { status: "confirmed"; bookingId: string };

type Event =
  | { type: "CHANGE"; field: keyof FormValues; value: string }
  | { type: "SUBMIT"; requestId: number }
  | { type: "SUBMIT_OK"; requestId: number; bookingId: string }
  | { type: "SUBMIT_ERROR"; requestId: number; message: string }
  | { type: "CANCEL" }
  | { type: "RETRY"; requestId: number }
  | { type: "RESET" };

const emptyValues: FormValues = { name: "", email: "", slot: "" };
export const initialState: State = { status: "editing", values: emptyValues, fieldErrors: {} };

// Guard: a condition that must hold for a transition to happen
function validate(values: FormValues): Partial<Record<keyof FormValues, string>> {
  const errors: Partial<Record<keyof FormValues, string>> = {};
  if (!values.name.trim()) errors.name = "Please enter your name";
  if (!values.email.includes("@")) errors.email = "Invalid email";
  if (!values.slot) errors.slot = "Pick a time slot";
  return errors;
}

export function reducer(state: State, event: Event): State {
  switch (state.status) {
    case "editing":
      switch (event.type) {
        case "CHANGE":
          return { ...state, values: { ...state.values, [event.field]: event.value } };
        case "SUBMIT": {
          const fieldErrors = validate(state.values);
          if (Object.keys(fieldErrors).length > 0) return { ...state, fieldErrors };
          return { status: "submitting", values: state.values, requestId: event.requestId };
        }
        default:
          return state;
      }
    case "submitting":
      switch (event.type) {
        case "SUBMIT_OK":
          // Ignore responses from stale requests (cancelled or superseded)
          if (event.requestId !== state.requestId) return state;
          return { status: "confirmed", bookingId: event.bookingId };
        case "SUBMIT_ERROR":
          if (event.requestId !== state.requestId) return state;
          return { status: "failed", values: state.values, message: event.message };
        case "CANCEL":
          return { status: "editing", values: state.values, fieldErrors: {} };
        default:
          return state; // a second SUBMIT is ignored — no manual disabling in five places
      }
    case "failed":
      switch (event.type) {
        case "RETRY":
          return { status: "submitting", values: state.values, requestId: event.requestId };
        case "CHANGE":
          return {
            status: "editing",
            values: { ...state.values, [event.field]: event.value },
            fieldErrors: {},
          };
        default:
          return state;
      }
    case "confirmed":
      return event.type === "RESET" ? initialState : state;
  }
}

A few things worth pointing out:

  • Switch on state first, event second. This makes the question "in this state, which events mean anything?" explicit. Anything not listed falls into default: return state.
  • Guards are conditions attached to transitions. SUBMIT only moves to submitting when validate returns no errors; otherwise the machine stays in editing with fieldErrors. The requestId check is a guard too: it blocks the race where a stale response arrives late.
  • The reducer is pure. It doesn't call APIs, it only computes the next state. That makes it trivial to test: call reducer(state, event) and compare — no rendering, no fetch mocks.

Where do side effects live?#

If the reducer can't call the API, where does the request run? I let the component (or a custom hook) react to state: entering submitting starts the request, leaving it cancels.

TSX
import { useEffect, useReducer, useRef } from "react";

declare function createBooking(values: FormValues, signal: AbortSignal): Promise<{ id: string }>;

export function useBookingForm() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const nextId = useRef(0);

  const submitting = state.status === "submitting" ? state : null;

  useEffect(() => {
    if (!submitting) return;
    const controller = new AbortController();
    const { requestId, values } = submitting;
    createBooking(values, controller.signal)
      .then((res) => dispatch({ type: "SUBMIT_OK", requestId, bookingId: res.id }))
      .catch((err: unknown) => {
        if (controller.signal.aborted) return;
        dispatch({ type: "SUBMIT_ERROR", requestId, message: err instanceof Error ? err.message : "Unknown error" });
      });
    return () => controller.abort(); // leaving the submitting state cancels the request
  }, [submitting]);

  return {
    state,
    change: (field: keyof FormValues, value: string) => dispatch({ type: "CHANGE", field, value }),
    submit: () => dispatch({ type: "SUBMIT", requestId: ++nextId.current }),
    retry: () => dispatch({ type: "RETRY", requestId: ++nextId.current }),
    cancel: () => dispatch({ type: "CANCEL" }),
    reset: () => dispatch({ type: "RESET" }),
  };
}

The effect depends on the submitting state object. Because the reducer only creates a new object on a real transition (ignored events return the exact same state), the effect re-runs only when the machine enters a new submission, and the cleanup cancels the request as soon as the machine leaves that state.

In development, React Strict Mode runs effects twice (mount, cleanup, mount again). With the setup above the first request is aborted and the second proceeds normally — but the server may still receive both. For non-idempotent operations like a booking, I send an idempotency key so the server can drop duplicates; that's worth doing in production anyway, because flaky networks cause retries too.

Rendering by state#

Once state is a union, the JSX becomes straightforward: one branch per state, no ambiguous flag combinations.

TSX
// Success, Pending and Fields are simple presentational components that only take props
function BookingForm() {
  const { state, change, submit, retry, cancel, reset } = useBookingForm();

  switch (state.status) {
    case "confirmed":
      return <Success bookingId={state.bookingId} onNew={reset} />;
    case "submitting":
      return <Pending values={state.values} onCancel={cancel} />;
    case "failed":
      return <Fields values={state.values} onChange={change} banner={state.message} onRetry={retry} />;
    case "editing":
      return <Fields values={state.values} errors={state.fieldErrors} onChange={change} onSubmit={submit} />;
  }
}

When the designer asks "what does the error screen look like?", you point at exactly one branch. When QA asks "which states need testing?", you hand them the transition table.

When a statechart library is worth it#

useReducer plus a discriminated union covers most components I write. But sometimes the machine grows to the point where hand-rolling it starts to hurt. Statecharts (a formalism introduced by David Harel and implemented by libraries such as XState) extend state machines with:

  • Hierarchical states: checkout.payment.verifying3DS — a CANCEL declared once on checkout applies to every child state.
  • Parallel states: a video player has a playing/paused state and an independent muted/unmuted state.
  • Delayed transitions and history: move on automatically after N seconds, return to the exact child state you left.
  • Actors and visualization tools: effects are declared as part of the machine, and you can look at the state diagram.
SituationWhat I pick
Fetching, a one-step form, a toggle with a few statesuseReducer + discriminated union
Multi-step wizard with back-navigation and cancel from any stepConsider a statechart
Several independent states running in parallel (media player, collaborative editor)Statechart
Business flow that must be shared and reviewed with people who don't read codeStatechart with a visual diagram
Server data that needs caching, deduping, refetchingA data-fetching library (TanStack Query, SWR) — don't hand-roll a machine

The last row deserves emphasis: for server data, data-fetching libraries already solve loading/error/stale along with caching and deduplication. Hand-written state machines fit best for interaction state — forms, wizards, uploads, verification steps — where the logic belongs to your UI itself.

The cost of a statechart library is a new API and a new way of thinking for the whole team. I only bring one in when the hand-written transition table no longer fits on one screen, or when the same event must be handled identically across many states.

Key takeaways#

  • Independent boolean flags create nonsensical state combinations; each new flag doubles them.
  • List the states, events and transition table before writing code — it's the best spec the whole team can read.
  • Use a discriminated union so data only exists in the states where it makes sense.
  • Switch on state first, then event, in the reducer; invalid events return the state unchanged.
  • Use guards for validation and to drop stale responses via a request id.
  • Keep the reducer pure; let effects react to state and clean up when the state is left.
  • Move to statecharts when you need nested or parallel states, or the machine is too big to read on one screen.