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:

TypeScript
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:

TypeScript
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:

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

Side-by-side state design comparison. Loose booleans on the left allow eight combinations including impossible states like loading + error + data. The discriminated union on the right has only four legal states — idle, loading, success, error — each carrying exactly the fields it needs.
Make impossible states unrepresentable. The compiler does the case analysis for you.

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.

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

TypeScript
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:

TypeScript
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

  1. Replace boolean soup with kind-tagged unions. Same data, fewer impossible combinations.
  2. Use switch on the discriminator with assertNever in the default branch — exhaustive at compile time.
  3. Reach for state machines when transitions matter as much as state.
  4. Keep transitions in one reducer — auditable, testable, replayable.
  5. 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 👊