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 angleA 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.
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.
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.