Skip to content

Finite State Machines for Complex UI Logic in TypeScript

Replace boolean flag spaghetti with finite state machines: a TypeScript-first approach to UI states that makes impossible states impossible by construction.

4 min read
TypeScript code showing a finite state machine transition definition for a form submission flow

Boolean flags are a tax. Every new edge case adds another isLoading, hasError, isRetrying, wasSubmitted — and soon you're maintaining a dozen booleans that can combine into states that should never exist. A form that's simultaneously isLoading and hasError from a previous attempt. A button that's disabled for three different reasons, none of them explicit. Finite state machines (FSMs) eliminate this entire class of bug at the type level.

The Boolean Spaghetti Problem

Here's a typical async form component. It's familiar because we've all written it.

tstypescript
// ❌ Implicit states — combinations like (loading=true, error=true) are possible
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSuccess, setIsSuccess] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
 
async function handleSubmit() {
  setIsLoading(true);
  setError(null); // Easy to forget this reset
  try {
    await submitForm(data);
    setIsSuccess(true);
  } catch (e) {
    setError(e.message);
  } finally {
    setIsLoading(false);
  }
}

The surface area of possible state combinations is 2⁴ = 16. Your component renders maybe four of them meaningfully, but the type system can't tell you that. Tests must guard against states that logically can't exist but technically can.

tstypescript
// ✅ Explicit states — only valid combinations exist
type FormState =
  | { status: "idle" }
  | { status: "submitting" }
  | { status: "success" }
  | { status: "error"; message: string }
  | { status: "retrying"; attempt: number };

One discriminated union. Five named states. No impossible combinations.

Modeling Transitions as a Machine

A finite state machine has three ingredients: states, events, and transitions. The machine lives in exactly one state at a time and moves between states only when a defined event fires.

tstypescript
type FormState =
  | { status: "idle" }
  | { status: "submitting" }
  | { status: "success" }
  | { status: "error"; message: string };
 
type FormEvent =
  | { type: "SUBMIT" }
  | { type: "RESOLVE" }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };
 
function formReducer(state: FormState, event: FormEvent): FormState {
  switch (state.status) {
    case "idle":
      if (event.type === "SUBMIT") return { status: "submitting" };
      return state;
 
    case "submitting":
      if (event.type === "RESOLVE") return { status: "success" };
      if (event.type === "REJECT") return { status: "error", message: event.message };
      return state;
 
    case "error":
      if (event.type === "SUBMIT") return { status: "submitting" };
      if (event.type === "RESET") return { status: "idle" };
      return state;
 
    case "success":
      if (event.type === "RESET") return { status: "idle" };
      return state;
  }
}

The key insight: unhandled transitions are silently ignored (return state). A RESOLVE event fired while in idle does nothing. This eliminates the entire category of "wrong order of operations" bugs without any defensive coding in the caller.

Wiring It to React

The reducer maps directly to useReducer. No external library required for machines of this size.

tstypescript
function ContactForm() {
  const [state, dispatch] = useReducer(formReducer, { status: "idle" });
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    dispatch({ type: "SUBMIT" });
 
    try {
      await submitContactForm(new FormData(e.currentTarget as HTMLFormElement));
      dispatch({ type: "RESOLVE" });
    } catch (err) {
      dispatch({
        type: "REJECT",
        message: err instanceof Error ? err.message : "Something went wrong",
      });
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {state.status === "error" && (
        // TypeScript narrows here — `message` only exists in the error state
        <p role="alert">{state.message}</p>
      )}
      {state.status === "success" ? (
        <p>Thanks — we'll be in touch.</p>
      ) : (
        <button disabled={state.status === "submitting"}>
          {state.status === "submitting" ? "Sending…" : "Send"}
        </button>
      )}
    </form>
  );
}

state.message in the error branch is narrowed by the discriminated union — that property only exists when status === "error". No optional chaining, no runtime guard. The compiler enforces correctness.

Guards and Extended State

Real machines often need guards — conditions that must hold before a transition fires. A checkout flow might only allow PROCEED if cart.items.length > 0. Guards keep business rules next to the transition they protect rather than scattered across event handlers.

tstypescript
type CheckoutState =
  | { status: "cart"; items: CartItem[] }
  | { status: "payment"; items: CartItem[]; total: number }
  | { status: "confirming"; orderId: string }
  | { status: "complete"; orderId: string };
 
type CheckoutEvent =
  | { type: "PROCEED" }
  | { type: "ORDER_CREATED"; orderId: string }
  | { type: "CONFIRM" };
 
function checkoutReducer(state: CheckoutState, event: CheckoutEvent): CheckoutState {
  switch (state.status) {
    case "cart": {
      if (event.type !== "PROCEED") return state;
      // Guard: can't proceed with an empty cart
      if (state.items.length === 0) return state;
 
      const total = state.items.reduce(
        (sum, item) => sum + item.price * item.qty,
        0,
      );
      return { status: "payment", items: state.items, total };
    }
 
    case "payment": {
      if (event.type === "ORDER_CREATED") {
        return { status: "confirming", orderId: event.orderId };
      }
      return state;
    }
 
    case "confirming": {
      if (event.type === "CONFIRM") {
        return { status: "complete", orderId: state.orderId };
      }
      return state;
    }
 
    default:
      return state;
  }
}

The total calculation happens exactly once — at transition time — and is stored in the machine's context. No derived state recomputed on every render, no risk of it being stale while a sibling component is updating.

Testing Machines in Isolation

Because the reducer is a pure function (state, event) => state, it's trivially testable without mounting a component or mocking hooks.

tstypescript
describe("formReducer", () => {
  it("transitions from idle to submitting on SUBMIT", () => {
    const next = formReducer({ status: "idle" }, { type: "SUBMIT" });
    expect(next).toEqual({ status: "submitting" });
  });
 
  it("ignores RESOLVE while idle", () => {
    const state = { status: "idle" } as const;
    const next = formReducer(state, { type: "RESOLVE" });
    expect(next).toBe(state); // Same reference — no allocation
  });
 
  it("captures error message on REJECT", () => {
    const next = formReducer(
      { status: "submitting" },
      { type: "REJECT", message: "Network timeout" },
    );
    expect(next).toEqual({ status: "error", message: "Network timeout" });
  });
});

Each test reads like a specification: given this state, when this event fires, expect this outcome. No mounted trees, no act() wrappers, no async setup. The entire transition table can be exhaustively covered in a few dozen fast unit tests.

When to Reach for a Library

Hand-rolled reducers cover most UI cases. For more complex orchestration — parallel states, hierarchical machines, delayed transitions, invoking async services — XState v5 is the right tool. The v5 API is significantly leaner than v4 and integrates cleanly with React via useMachine.

~

You don't need XState for a three-state toggle. Use it when your machine needs to invoke services, manage timers, or coordinate parallel regions. Over-engineering a simple fetch wrapper into a full state chart is its own anti-pattern.

ComplexityRecommended approach
2–4 states, linear transitionsDiscriminated union + useState
4–8 states, guards, computed contextTyped reducer with useReducer
Parallel regions, async invocations, nested statesXState v5

The gradient matters. A three-state button doesn't need a state chart. A multi-step checkout with background polling, timeout recovery, and concurrent validation does.

Key Takeaways

  1. Replace boolean flags with discriminated unions — status: "idle" | "submitting" | "error" | "success" makes impossible state combinations impossible at the type level.
  2. Transitions are the source of truth — define what events are valid in each state and return state unchanged for everything else.
  3. Guards belong inside the reducer — business rules that gate a transition should live next to the transition itself, not scattered across components.
  4. Pure reducers are trivially testable — no component mounting, no async ceremony; test each (state, event) pair as a direct function call.
  5. Reach for XState when machines grow hierarchical — parallel regions and async service invocations are exactly what it was built for; don't fight a hand-rolled reducer to get there.
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX