10LOC
Reactadvanced

useReducer with a discriminated-union action type

Published January 5, 2027

type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; error: string };

type Action = { type: "fetch" } | { type: "success"; data: string[] } | { type: "failure"; error: string };

export const searchReducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "fetch":
      return { status: "loading" };
    case "success":
      return { status: "success", data: action.data };
    case "failure":
      return { status: "error", error: action.error };
    default: {
      const exhaustiveCheck: never = action;
      return exhaustiveCheck;
    }
  }
};

What

Modeling reducer state and actions as discriminated unions turns "did I handle every action?" into a compile-time check instead of a runtime bug you find later.

Why it matters

A reducer with loosely-typed state ({ status: string; data?: T; error?: string }) lets you construct nonsense states — status: "error" with no error message, or status: "success" with stale data from a previous request. A discriminated union makes each status carry exactly the fields that make sense for it, and TypeScript narrows state automatically once you check .status. The same applies to actions: forgetting to handle a new action variant is easy to miss in a large switch, and easy to catch if the reducer is written so a missing case fails to compile rather than silently falling through.

How it works

  • State is a union tagged by status; each variant only has the fields relevant to it — data only exists when status is "success", error only when it's "error".
  • Action is tagged by type the same way, so action.data is only accessible inside the "success" case, and TypeScript would flag it as an error anywhere else.
  • The switch on action.type handles each variant explicitly; TypeScript narrows action inside each case.
  • default is where the exhaustiveness check lives: once every real case is handled, action's remaining type is never. Assigning it to const exhaustiveCheck: never only compiles if that's true — add a new Action variant without updating the switch, and this line stops compiling until you do.

Gotchas

  • The exhaustiveness check only works if default is reachable in the type system — every case needs an explicit return; a fallthrough, or a default that returns something other than exhaustiveCheck, silently defeats it. This is also why the type declarations here keep lineWaiver: true: they're the entire point of the pattern, not padding to trim.
  • This is a compile-time guarantee, not a runtime one — malformed data from JSON.parse or an untyped API boundary can still produce an object that doesn't match any case, so runtime switches on external data still want a real fallback, not just a type-level one.
  • Plug this into a component with useReducer(searchReducer, { status: "idle" }); the reducer function itself has no React dependency and is easy to unit test in isolation.

Related

  • useState with separate booleans (isLoading, isError, ...) — the pattern this replaces; it allows the same impossible combinations (isLoading && isError both true) that a tagged union rules out.
  • Zod or another runtime schema validator — the right complement when the "action" is untrusted input rather than something your own code dispatches.