The State Machine Hiding Inside Every Serious Component
Most UI bugs are impossible state bugs. Making the state machine explicit — with discriminated unions or XState — removes entire categories of them.
Every non-trivial UI component contains a state machine. A dialog transitions through closed → opening → open → closing → closed. A multi-step form moves through steps in one direction and cannot skip required transitions. A button goes idle → loading → success | error. The machine is always there. The question is whether you make it explicit or let it accumulate as a pile of booleans that can disagree with each other.
The Impossible State Problem
When you reach for multiple boolean flags, you create states the component was never designed to handle:
// This state is possible in code but impossible in the real UI
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isClosing, setIsClosing] = useState(false);
// isOpen: true, isLoading: true, isClosing: true — what does this even mean?
Three booleans give you eight possible combinations. Most of them are invalid. You guard against the invalid ones with conditionals scattered across the render function, and the guards go out of sync during refactors.
The fix is to make invalid states unrepresentable. A discriminated union cannot be open and closing at the same time.
Discriminated Unions as State Machines
TypeScript's discriminated unions map directly to state machine states. Each kind value is a node in the graph, and the data attached to each state only exists when that state is active.
type DialogState =
| { kind: "closed" }
| { kind: "opening" }
| { kind: "open"; content: React.ReactNode }
| { kind: "closing"; content: React.ReactNode };
type DialogAction =
| { type: "OPEN"; content: React.ReactNode }
| { type: "ANIMATION_COMPLETE" }
| { type: "CLOSE" };
function dialogReducer(state: DialogState, action: DialogAction): DialogState {
switch (state.kind) {
case "closed":
if (action.type === "OPEN") return { kind: "opening" };
return state;
case "opening":
if (action.type === "ANIMATION_COMPLETE")
return { kind: "open", content: (action as any).content };
return state;
case "open":
if (action.type === "CLOSE") return { kind: "closing", content: state.content };
return state;
case "closing":
if (action.type === "ANIMATION_COMPLETE") return { kind: "closed" };
return state;
default:
return state;
}
}
Notice what this makes impossible: the dialog cannot be simultaneously open and closing. The content prop only exists in states where the dialog is visible. If you try to access state.content when state.kind === "closed", TypeScript refuses to compile.
useReducer as a Light State Machine
For most components, useReducer with a discriminated union is sufficient. It gives you:
- A single source of truth for the current state
- Transitions as the only mutation mechanism
- A reducer that is easy to test in isolation
function Dialog({ trigger, children }: DialogProps) {
const [state, dispatch] = useReducer(dialogReducer, { kind: "closed" });
return (
<>
<button onClick={() => dispatch({ type: "OPEN", content: children })}>
{trigger}
</button>
{(state.kind === "open" || state.kind === "closing") && (
<div
data-state={state.kind}
onAnimationEnd={() => dispatch({ type: "ANIMATION_COMPLETE" })}
>
{state.content}
<button onClick={() => dispatch({ type: "CLOSE" })}>Close</button>
</div>
)}
</>
);
}
The render function reads directly from state.kind. There are no derived booleans, no sync issues, no impossible combinations to guard against.
When to Reach for XState
useReducer covers most component-level machines. XState earns its complexity when:
- The machine needs to be shared across multiple components
- You need parallel states (e.g., a form that is simultaneously validating and autosaving)
- You want visual tooling — the XState visualizer is genuinely useful for complex flows
- The machine needs to invoke async effects as part of transitions
XState's createMachine also forces you to enumerate all states upfront, which is its own form of documentation. You cannot add a boolean flag to handle an edge case — you have to add a state.
import { createMachine } from "xstate";
const dialogMachine = createMachine({
id: "dialog",
initial: "closed",
states: {
closed: { on: { OPEN: "opening" } },
opening: { on: { ANIMATION_COMPLETE: "open" } },
open: { on: { CLOSE: "closing" } },
closing: { on: { ANIMATION_COMPLETE: "closed" } },
},
});
When the Implicit Machine Becomes a Liability
The pattern of isLoading && !isError && !isSuccess is a hand-written state machine check. When you find yourself writing guards like that, you already have a state machine — you just have not named it yet. The liability is that every new boolean flag expands the state space exponentially, and no tooling will warn you when you reach an impossible combination. A UI state checklist can help you audit all the states a component needs to handle before you write a single line.
Start with a discriminated union. Add useReducer. Only reach for XState when the machine needs to cross component boundaries or handle concurrent states. The goal is not state management sophistication — it is making illegal states compile-time errors rather than runtime bugs. And remember that motion is not delight — it is state: the opening and closing transitions exist precisely so animation can communicate what the machine is doing.