typescript 101 lines · 3 tabs

Discriminated-Union State Machine for a React Data-Fetching Hook

Shared by codesnips Jul 2026
3 tabs
export type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

export type FetchEvent<T> =
  | { type: 'FETCH' }
  | { type: 'RESOLVE'; data: T }
  | { type: 'REJECT'; error: Error }
  | { type: 'RESET' };

export function fetchReducer<T>(
  state: FetchState<T>,
  event: FetchEvent<T>
): FetchState<T> {
  switch (event.type) {
    case 'FETCH':
      return { status: 'loading' };
    case 'RESOLVE':
      return { status: 'success', data: event.data };
    case 'REJECT':
      return { status: 'error', error: event.error };
    case 'RESET':
      return { status: 'idle' };
    default:
      return state;
  }
}

export const initialState: FetchState<never> = { status: 'idle' };
3 files · typescript Explain with highlit

This snippet models the lifecycle of an async fetch as an explicit state machine whose states are encoded as a TypeScript discriminated union, rather than the common tangle of independent isLoading, error, and data booleans. That loose approach permits impossible combinations — loading and error at once, or data present while still loading a fresh request — and forces consumers to guard against states that should never occur.

In fetchMachine.ts, the FetchState<T> union has four members (idle, loading, success, error), each carrying exactly the fields that are valid in that state: only success has data, only error has error. The shared string literal status is the discriminant, so narrowing on state.status lets the compiler know which fields exist. The transitions live in a pure fetchReducer, which pattern-matches on the incoming FetchEvent and returns the next state. Because it is a plain function of (state, event), it is trivial to unit test and reason about, and illegal transitions simply have no case.

The useFetch.ts tab wires that reducer into React via useReducer and drives it from an effect. Each run creates an AbortController so an in-flight request is cancelled when dependencies change or the component unmounts; this prevents the classic race where a slow earlier response overwrites a newer one. The effect dispatches FETCH before awaiting, then RESOLVE or REJECT, while swallowing AbortError so a deliberate cancellation never flips the machine into the error state. useFetch returns the union directly, preserving the discriminant for callers.

In UserCard.tsx, the consumer switches over state.status, and inside each branch TypeScript narrows the type: state.data is only reachable under success, state.error only under error. The default branch with never gives an exhaustiveness check, so adding a new state to the union becomes a compile error until every consumer handles it.

This pattern is worth reaching for whenever a component juggles several mutually exclusive async phases; it trades a little upfront type ceremony for unrepresentable illegal states and self-documenting consumers. The main trade-off is verbosity, which is usually a fair price for eliminating an entire class of runtime bugs.


Related snips

Share this code

Here's the card — post it anywhere.

Discriminated-Union State Machine for a React Data-Fetching Hook — share card
Link copied