Modeling UI with State Machines and Discriminated Unions
Model asynchronous and multi-step UI flows as explicit finite state machines using TypeScript discriminated unions, eliminate impossible states from component state, and know when a dedicated library like XState is worth adopting.
Most 'weird bug that only happens sometimes' reports in React apps trace back to the same root cause: state was modeled as several independent booleans (isLoading, isError, data) that can combine into combinations that were never supposed to exist — loading and error both true, data present but isLoading also true, and so on. A state machine fixes this at the type level by naming the states explicitly and making illegal combinations literally impossible to construct. This lesson builds that model from first principles using nothing but TypeScript's discriminated unions, then discusses when a dedicated library like XState earns its keep.
States, events, and transitions
A state machine has three core ingredients. States are the finite, named situations a system can be in — for a data-fetching flow: idle, loading, success, error. Events are things that happen to the machine from outside — a fetch starts, a fetch resolves, a fetch fails, a user retries. Transitions are the rules for which event, applied to which current state, produces which next state.
The crucial discipline is that a transition table is exhaustive and explicit: for every state, you decide exactly which events are even valid, and what happens for each. An event that isn't defined for the current state simply does nothing (or is a bug you want to catch) — it is not allowed to silently corrupt state into a combination you never designed for.
This is a shift in mindset from 'update this boolean, then update that boolean' to 'name the next state and transition wholesale.' Instead of setIsLoading(true) followed later by a separate setError(err) call that might leave isLoading stuck at true, a machine-based update replaces the entire state object atomically: setState({ status: 'error', error: err }).
Impossible states and discriminated unions
Consider the classic naive shape for an async fetch: { isLoading: boolean; isError: boolean; data: Data | null; error: Error | null }. This type permits 16 combinations of the two booleans and two nullable fields, but only 4 of them are ever meaningful (idle, loading, success-with-data, error-with-error). The other 12 — loading and error both true, data present while isLoading is also true, error present but isError false — are impossible states that TypeScript will happily let you construct and that your components must still defensively code around.
A discriminated union eliminates this by using one literal 'tag' field (conventionally named status or kind) that TypeScript can narrow on, with each variant only including the fields that are actually valid for that state. Once you match on the tag with a switch, TypeScript knows exactly which other fields exist — data is only accessible inside the success branch, error only inside the error branch. The compiler now refuses to let you write code that reads data while the state might be loading.
This isn't just tidier — it deletes bug categories entirely. You cannot forget to clear isLoading on error, because there is no isLoading field to forget; there is only ever one status value at a time, and setting it to 'error' makes the entire previous state disappear atomically.
Modeling the fetch lifecycle: idle → loading → success/error
The canonical example for this module is a fetch lifecycle: the machine starts idle, a FETCH_STARTED event moves it to loading, and from loading it can only go to one of two terminal-ish states — success (with data) or error (with an error) — never both, and never neither. From either success or error, a RETRY event can send it back to loading, restarting the cycle.
Notice what is *not* representable: there is no way to be simultaneously loading and errored, no way to have success data while also being loading, and no way to be in an undefined fifth state. The diagram below is the exact shape this lesson's discriminated union implements.
When to reach for a dedicated library (XState and friends)
A hand-rolled discriminated union plus a useReducer is enough for the large majority of component-level state in a React app, and you should default to it — it has zero dependencies, is fully typed with no extra tooling, and is easy for any React developer to read. Reach for a dedicated state machine library like XState when the flow grows real machine-specific complexity: nested/hierarchical states (a state that itself contains sub-states), parallel regions that need to be tracked independently, guarded transitions with complex conditions, delayed/scheduled transitions, or when you want to visualize, test, or statically verify the machine's transition graph outside of the component that uses it.
XState models the exact same core concepts — states, events, transitions — but formalizes them into a standalone machine definition (following the SCXML statechart standard) that can be visualized, simulated, and tested independently of any UI framework, then wired into React via @xstate/react's useMachine hook. The rule of thumb: if you find yourself hand-building nested switch statements to track 'which sub-flow of the sub-flow' the user is in, or you need the same machine to drive both a React UI and a background worker, that complexity is XState's sweet spot — reinventing it by hand tends to reproduce a worse, less-tested version of what the library already provides.
• Default: discriminated union + useReducer for most component and hook-level state. • Consider XState when: nested/hierarchical states, parallel regions, guarded/delayed transitions appear. • Consider XState when: the machine needs to be visualized, tested, or shared outside a single component. • XState models the same states/events/transitions vocabulary — it's a superset of tooling, not a different mental model.
Discriminated union for an async fetch state machine
status is the discriminant TypeScript narrows on — inside a case 'success': branch, state.data is known to exist; in every other branch it doesn't even type-check as accessible. There is no isLoading boolean anywhere, so there is nothing to forget to reset.
// Each variant only carries the fields valid for that exact state.
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
// Events are also a discriminated union: every valid thing that can happen.
type FetchEvent<T> =
| { type: "FETCH_STARTED" }
| { type: "FETCH_SUCCEEDED"; data: T }
| { type: "FETCH_FAILED"; error: string }
| { type: "RETRY" };
function fetchReducer<T>(state: FetchState<T>, event: FetchEvent<T>): FetchState<T> {
switch (event.type) {
case "FETCH_STARTED":
return { status: "loading" };
case "FETCH_SUCCEEDED":
return { status: "success", data: event.data };
case "FETCH_FAILED":
return { status: "error", error: event.error };
case "RETRY":
return { status: "loading" };
default:
return state; // unknown event: no-op, never a corrupted in-between state
}
}Code Examples
Every render path handles exactly one of the four real states. There's no branch where data could be null while isLoading is also true — that combination was never representable in the type, so it can't leak into the JSX. The cancelled flag guards against a stale response for an old id overwriting a newer request's result — a common race-condition bug in naive fetch effects, orthogonal to the state machine itself but worth pairing with it.
case "success": return <h1>{state.data.title}</h1>;
The naive shape lets four independent fields drift out of sync; the discriminated union collapses them into one field that can only ever hold one of four valid shapes, so TypeScript itself rejects any attempt to mix fields from different states.
const buggy: NaiveState<Post> = { isLoading: true, isError: true, ... };
Common mistake: Adding a fifth boolean flag (isRetrying) to the naive shape instead of stepping back to ask whether the state model itself needs to change — this is how boolean-soup state grows over a codebase's lifetime.
interface Post { id: number; title: string }
function usePostFetch(id: number) {
const [state, dispatch] = React.useReducer(fetchReducer<Post>, { status: "idle" });
React.useEffect(() => {
let cancelled = false;
dispatch({ type: "FETCH_STARTED" });
fetch(`/api/posts/${id}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<Post>;
})
.then((data) => {
if (!cancelled) dispatch({ type: "FETCH_SUCCEEDED", data });
})
.catch((err: Error) => {
if (!cancelled) dispatch({ type: "FETCH_FAILED", error: err.message });
});
return () => { cancelled = true; };
}, [id]);
return state;
}
function PostView({ id }: { id: number }) {
const state = usePostFetch(id);
// The switch below is exhaustive over exactly 4 real states — nothing else compiles.
switch (state.status) {
case "idle":
return null;
case "loading":
return <p>Loading...</p>;
case "success":
return <h1>{state.data.title}</h1>; // state.data exists ONLY here, by construction
case "error":
return <p role="alert">Failed: {state.error}</p>; // state.
…// BEFORE — impossible states are representable and must be defended against everywhere.
interface NaiveState<T> {
isLoading: boolean;
isError: boolean;
data: T | null;
error: string | null;
}
// This compiles, and is nonsensical:
const buggy: NaiveState<Post> = {
isLoading: true, isError: true, data: { id: 1, title: "x" }, error: null,
};
// AFTER — the same nonsensical combination simply does not type-check.
const fixed: FetchState<Post> = { status: "loading" };
// const invalid: FetchState<Post> = { status: "loading", data: { id: 1, title: "x" } };
// ^ Error: Object literal may only specify known properties, and 'data' does not
// exist in type '{ status: "loading" }'.Fetch lifecycle state machine
This is the exact machine the FetchState<T> discriminated union and fetchReducer above implement — idle → loading → [success, error], with RETRY looping back to loading.
FETCH_STARTED
idle ─────────────────▶ loading
│ │
FETCH_SUCCEEDED│ │FETCH_FAILED
▼ ▼
success error
│ │
RETRY │ │ RETRY
▼ ▼
loading (cycle repeats)
Only 4 states exist. From "loading", exactly two outcomes are possible —
never both, never neither. "success" and "error" never coexist.Important Rules
Model mutually exclusive states as one discriminated union field, never as multiple independent booleans.
Every variant should carry only the data that is actually valid in that state — resist adding an 'extra' optional field to a variant that doesn't need it.
Write reducers as exhaustive switches over the event type; let TypeScript's exhaustiveness checking (a never fallthrough) catch missed cases.
Treat an unmatched event for the current state as a no-op, not a silent partial update.
Reach for XState (or similar) only once you have concrete nested/parallel/guarded complexity — not preemptively.
Where this shows up in production
Any UI with an async boundary benefits: data fetching (shown above), multi-step checkout or onboarding wizards ({ step: 'shipping' } | { step: 'payment' } | { step: 'review' } | { step: 'complete' }), file upload progress (idle | uploading | processing | done | failed), and authentication flows (signedOut | authenticating | signedIn | authError). Teams that adopt this pattern broadly report a large drop in 'impossible state' bug reports — QA can no longer reproduce a screen that shows both a spinner and an error message, because that combination no longer exists as a value the type system will accept.
type State = { isLoading: boolean; isError: boolean; data: Post | null };
type State = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: Post } | { status: 'error'; error: string };
The boolean-flag version allows 2x2x2 = 8 combinations for 4 real intended states; the discriminated union allows exactly the 4 that are meaningful, and nothing else compiles.
case 'success': return <h1>{(state as any).data.title}</h1>; // cast to silence the error
Structure the switch so each case's variant is inferred automatically — don't fight the compiler with as any.
If you need a cast to access a field, the union is usually structured wrong (often a missing discriminant, or fields spread across the wrong variant) rather than the compiler being overly strict.
A naive fetch state uses { isLoading: boolean; isError: boolean; data: T | null }. What is the core problem with this shape? A. It's slower at runtime than a discriminated union. B. It allows combinations, like isLoading and isError both true, that were never meant to be valid. C. TypeScript cannot type booleans. D. It requires a class component to use correctly.
B. It allows combinations, like isLoading and isError both true, that were never meant to be valid. — Independent boolean/nullable fields multiply into far more combinations than the number of states actually intended, and TypeScript can't stop you from constructing the nonsensical ones.
In the FetchState<T> discriminated union, what makes state.data safely accessible only inside the case 'success': branch? A. A runtime if (state.data) check the developer must remember to add. B. The status discriminant field, which TypeScript narrows the union on inside each switch case. C. The data field is marked readonly. D. React automatically strips unused fields per render.
B. The status discriminant field, which TypeScript narrows the union on inside each switch case. — TypeScript narrows a discriminated union by its literal tag field (status here); once matched to 'success', the compiler knows only that variant's shape applies, so data is available and typed, with no runtime check needed.
When is it worth adopting a dedicated library like XState instead of a hand-rolled reducer? A. Always — every piece of component state should use XState. B. Never — XState is only useful for backend workflow engines. C. When the flow has real machine complexity: nested/parallel states, guarded or delayed transitions, or a need to visualize/test the machine independently of the UI. D. Only when the app has fewer than three components.
C. When the flow has real machine complexity: nested/parallel states, guarded or delayed transitions, or a need to visualize/test the machine independently of the UI. — A discriminated union plus useReducer covers most component-level needs with zero dependencies. XState earns its cost once you hit genuine statechart complexity — hierarchical/parallel states, guards, delays, or cross-framework machine reuse.
Model a multi-step signup wizard with three steps — 'account details', 'verify email', 'done' — as a discriminated union, plus events for advancing between them and going back a step from 'verify email' to 'account details'. Write the reducer.
type WizardState = { step: 'accountDetails' } | { step: 'verifyEmail'; email: string } | { step: 'done' }; type WizardEvent = { type: 'SUBMIT_ACCOUNT'; email: string } | { type: 'EMAIL_VERIFIED' } | { type: 'GO_BACK' }; function wizardReducer(state: WizardState, event: WizardEvent): WizardState { switch (event.type) { case 'SUBMIT_ACCOUNT': return { step: 'verifyEmail', email: event.email }; case 'EMAIL_VERIFIED': return { step: 'done' }; case 'GO_BACK': return state.step === 'verifyEmail' ? { step: 'accountDetails' } : state; default: return state; } }
Give each step variant only the fields it actually needs (e.g., the 'verify email' step might carry the email address being verified). Define events like SUBMIT_ACCOUNT, EMAIL_VERIFIED, and GO_BACK.