A frontend bug pattern as old as JSX: loading, data, and error are three booleans/values that can be in 8 combinations, but only 4 of them are actually valid. The other 4 happen anyway because nothing prevents them. The UI shows a spinner and an error and the cached data, all at once.
The fix isn't more careful coding. It's making the bad combinations unrepresentable in the type system.
Booleans Create Impossible States
The pattern that ages badly:
type FetchState = {
loading: boolean;
data: User | null;
error: Error | null;
};
Three fields. 2³ = 8 possible combinations. Some are valid: {loading: true, data: null, error: null} (loading), {loading: false, data: User, error: null} (loaded). Others are nonsense: {loading: true, data: User, error: Error} (loading and loaded and errored?). The type system allows them all.
Every component that renders this state has to guess. Spinner if loading? But what if there's also data — show stale data? What about error? Most apps end up with rendering logic that's longer than the actual UI, all of it untangling combinations that shouldn't exist.
Discriminated Unions Model Reality
The same state, expressed honestly:
type FetchState<T> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'loaded'; data: T }
| { kind: 'error'; error: Error };
Now there are exactly 4 states. The kind field discriminates them. The compiler knows that if kind === 'loaded', then data exists; otherwise it doesn't. The 4 invalid combinations from before cannot be constructed — the type refuses them.
Rendering becomes a clean switch:
function UserView({ state }: { state: FetchState<User> }) {
switch (state.kind) {
case 'idle': return <Idle />;
case 'loading': return <Spinner />;
case 'loaded': return <Profile user={state.data} />;
case 'error': return <ErrorMessage error={state.error} />;
}
}
No nested if. No "but what if both?". The code mirrors the model.
State Machines Help Complex Flows
For state with rules about transitions (not just shape), explicit state machines pay back. Every transition is a named event; the machine knows which events are valid in which state.
type CheckoutState =
| { kind: 'cart'; items: Item[] }
| { kind: 'shipping'; items: Item[]; address: Address }
| { kind: 'payment'; items: Item[]; address: Address; method: PaymentMethod }
| { kind: 'submitting'; orderId: string }
| { kind: 'success'; orderId: string }
| { kind: 'error'; reason: string; retryFrom: 'cart' | 'shipping' | 'payment' };
type CheckoutEvent =
| { type: 'addItem'; item: Item }
| { type: 'goToShipping'; address: Address }
| { type: 'goToPayment'; method: PaymentMethod }
| { type: 'submit' }
| { type: 'retry' };
A reducer that handles each (state, event) pair makes invalid transitions a compile error. "User submits payment from the cart" can't happen because the type system refuses to construct it.
XState is the canonical library for full state machines. For simpler cases, a switch-based reducer does the job without a dependency.
Reducers Keep Transitions Visible
Even outside formal state machines, reducers force transitions to be explicit. Every state change goes through one place.
function checkoutReducer(state: CheckoutState, event: CheckoutEvent): CheckoutState {
switch (state.kind) {
case 'cart':
if (event.type === 'addItem') return { ...state, items: [...state.items, event.item] };
if (event.type === 'goToShipping') return { kind: 'shipping', items: state.items, address: event.address };
return state;
case 'shipping':
if (event.type === 'goToPayment') return { kind: 'payment', items: state.items, address: state.address, method: event.method };
return state;
// ... etc
}
}
Reading the reducer tells you every legal transition in the app. Compare that to a codebase where state mutations are sprinkled across 30 components — there's no central place to know what's allowed.
Combine With Validation
The discriminated union pattern works inside the app. At the boundary, it pairs with runtime schema validation:
import { z } from 'zod';
const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>;
async function load(id: string): Promise<FetchState<User>> {
try {
const data = UserSchema.parse(await fetch(`/api/users/${id}`).then(r => r.json()));
return { kind: 'loaded', data };
} catch (err) {
return { kind: 'error', error: err as Error };
}
}
Schema parses the runtime data. Discriminated union represents the resulting state. Both layers cooperate to keep impossible states out of the UI.
Pro Tips
- Replace boolean soup with
kind-tagged unions. Same data, fewer impossible combinations. - Use
switchon the discriminator withassertNeverin the default branch — exhaustive at compile time. - Reach for state machines when transitions matter as much as state.
- Keep transitions in one reducer — auditable, testable, replayable.
- Validate at the boundary. Discriminated unions inside; schemas at the edge.
Final Tips
The biggest improvement to my own frontend code came from a single mental shift: model the valid states, not the fields. Once invalid combinations can't exist, half the rendering complexity disappears. The components shrink. The bugs go elsewhere.
Pick four valid states. Reject the eight invalid combinations. Write the rendering code that matches.
Good luck — and may your UIs never spin and error at the same time 👊




