Skip to content
fearchitect
State & Data

State Machines

Model UI as explicit states with typed transitions to kill impossible states.

By Abas TurabliReviewed

Summary

Boolean flag soup (`isLoading && isError`) lets the UI enter states that can't exist in reality. A state machine makes every valid state and transition explicit, so the impossible becomes unrepresentable. Use `useReducer` for simple cases; reach for XState v5 (`setup().createMachine()`) when you need hierarchical states, side effects as actors, or visual tooling.

Jump to the interview angle

A state machine defines a finite set of states (idle, loading, success, error) and the events that move between them. At any moment the machine is in exactly one state.

The classic failure mode: four booleans (isLoading, isError, isEmpty, hasData) can represent 16 combinations, but only 4 are valid. The other 12 are bugs.

Model instead as a discriminated union — { status: "loading" | "error" | "empty" | "success"; data?: T } — and the invalid combinations become type errors.

A statechart extends machines with hierarchy (sub-states) and parallel regions, making multi-step forms or media players tractable.

Four states, six transitions — every arrow is explicit code; the 12 impossible flag combinations never exist.

XState v5 — setup + createMachine + useMachine

Two approaches: useReducer — discriminated-union state, event union, pure reducer; TypeScript exhaustiveness catches missing transitions. XState v5 — call setup({ types, actors }) then chain .createMachine(); async work becomes a fromPromise actor invoked from a state, transitioning on onDone/onError.

XState v5 — setup + createMachine + useMachine in Reacttsx
import { assign, setup, fromPromise } from "xstate";
import { useMachine } from "@xstate/react";

const fetchMachine = setup({
  types: {
    context: {} as { data: string[]; error: string },
    events: {} as { type: "FETCH" } | { type: "RESET" },
  },
  actors: {
    loadItems: fromPromise(async () => fetchItems()),
  },
}).createMachine({
  id: "fetch",
  initial: "idle",
  context: { data: [], error: "" },
  states: {
    idle: {
      on: { FETCH: "loading" },
    },
    loading: {
      invoke: {
        src: "loadItems",
        onDone: {
          target: "success",
          actions: assign({ data: ({ event }) => event.output }),
        },
        onError: {
          target: "error",
          actions: assign({ error: ({ event }) => (event.error as Error).message }),
        },
      },
    },
    success: {
      on: { RESET: "idle" },
    },
    error: {
      on: { FETCH: "loading" },
    },
  },
});

function DataLoader() {
  const [state, send] = useMachine(fetchMachine);

  if (state.matches("idle")) return <button onClick={() => send({ type: "FETCH" })}>Load</button>;
  if (state.matches("loading")) return <Spinner />;
  if (state.matches("error")) return <p>{state.context.error}</p>;
  return <List items={state.context.data} />;
}

setup() declares the loadItems actor once; loading invokes it via invoke.src. On onDone, assign writes event.output to context. React reads only state.value and state.context — async logic stays in the machine.

Tradeoffs

Pros

  • Impossible states become unrepresentable — no `isLoading && isError` bugs.
  • Transitions are explicit; any developer can read the machine and know every valid path.
  • XState actors isolate async side effects from render logic, making them independently testable.
  • `useReducer` machines need zero dependencies and add no bundle weight.
  • Statecharts visualise directly in Stately Studio for design–dev handoff.

Cons

  • XState v5 adds ~15 KB min+gzip; overkill for simple flag toggles.
  • Setup cost is higher than ad-hoc state — requires upfront state enumeration.
  • Teams unfamiliar with statechart notation must learn `invoke`, `entry`/`exit`, and guard semantics.
  • `setup().createMachine()` verbosity can feel heavy for a two-state toggle.

Interview angle

Interviewers ask this to test whether you default to flags or think in states. The strong answer names the impossible-state problem, shows a discriminated-union type, and knows when XState's actor model is worth the weight versus a plain useReducer.

Soundbite: "Four booleans give 16 states; only 4 are valid. Model the 4, and the compiler kills the other 12."

Key terms

finite state machine
A model with a fixed set of states, one active at a time, and explicit transitions between them.
statechart
An extended state machine with hierarchy, parallel regions, and entry/exit actions.
discriminated union
A TypeScript union where a shared `type` or `status` field narrows each branch.
actor (XState)
A running process that receives events, holds state, and can spawn child actors.
guard
A boolean function on context and event that conditionally allows a transition.

Further reading

Search fearchitect

Jump to a topic, mode, or action.