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
Stateis a union tagged bystatus; each variant only has the fields relevant to it —dataonly exists whenstatusis"success",erroronly when it's"error".Actionis tagged bytypethe same way, soaction.datais only accessible inside the"success"case, and TypeScript would flag it as an error anywhere else.- The
switchonaction.typehandles each variant explicitly; TypeScript narrowsactioninside eachcase. defaultis where the exhaustiveness check lives: once every real case is handled,action's remaining type isnever. Assigning it toconst exhaustiveCheck: neveronly compiles if that's true — add a newActionvariant without updating theswitch, and this line stops compiling until you do.
Gotchas
- The exhaustiveness check only works if
defaultis reachable in the type system — every case needs an explicitreturn; a fallthrough, or adefaultthat returns something other thanexhaustiveCheck, silently defeats it. This is also why the type declarations here keeplineWaiver: 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.parseor an untyped API boundary can still produce an object that doesn't match any case, so runtimeswitches 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
useStatewith separate booleans (isLoading,isError, ...) — the pattern this replaces; it allows the same impossible combinations (isLoading && isErrorbothtrue) 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.