Right, so you’ve graduated from the tyranny of ten useState calls for a single component and you’re ready to embrace useReducer. It feels more grown-up, doesn’t it? Like switching from a scooter to a manual transmission. But with TypeScript, you’re not just getting the keys—you’re also getting the full, annotated repair manual. Our job is to make sure that manual doesn’t read like it was translated through three languages.

The core idea of useReducer is simple: you give it a reducer function (state, action) => newState and an initial state, and it gives you back the current state and a dispatch function to send actions. TypeScript’s job is to lock this whole system down, making it impossible to dispatch an action you didn’t account for or to access a state property that doesn’t exist. It’s your personal bouncer for state management.

Defining Your State and Actions

First things first, we need to define the shape of our world. We’ll start with a classic example: a state for a counter that can also handle a text input. Because what’s a tutorial without a counter?

interface State {
  count: number;
  text: string;
}

Now, for the actions. This is where most people get it wrong. You could define actions as a generic union like { type: string; payload?: any }, but if you do that, I will know. And I will be disappointed. You’ve just turned off TypeScript and gone back to JavaScript. The correct way is to define each action as a distinct discriminated union.

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'setCount'; payload: number }
  | { type: 'setText'; payload: string };

See what we did there? Each action is a specific object with a type discriminator. The payload property is explicitly typed for each action that needs it. Trying to dispatch { type: 'setCount', payload: 'hello' } will cause a compile-time error. This is your first and best line of defense against nonsense.

Writing the Reducer: No More Switch Defaults

With our types defined, writing the reducer becomes an exercise in glorious, guided correctness.

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    case 'decrement':
      return { ...state, count: state.count - 1 };
    case 'setCount':
      return { ...state, count: action.payload };
    case 'setText':
      return { ...state, text: action.payload };
    default:
      // This is the secret sauce. Read on.
      const _exhaustiveCheck: never = action;
      return state;
  }
}

Notice the default case? We assign action to a variable of type never. This is our exhaustive check. If we ever add a new action to the Action union but forget to handle it in the reducer, TypeScript will throw a compile-time error on that line because action will not be assignable to never. It’s like having a unit test built directly into your type definitions. You’re welcome.

Using useReducer in the Component

Now for the payoff. When we call useReducer, we pass it our reducer function and the initial state. TypeScript will infer everything from there.

import { useReducer } from 'react';

const initialState: State = {
  count: 0,
  text: 'Hello',
};

function CounterComponent() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <p>Text: {state.text}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <input
        type="number"
        value={state.count}
        onChange={(e) => dispatch({ type: 'setCount', payload: Number(e.target.value) })}
      />
      <input
        value={state.text}
        onChange={(e) => dispatch({ type: 'setText', payload: e.target.value })}
      />
    </div>
  );
}

The beauty here is that state is fully typed as State, and dispatch only accepts arguments that match the Action union. Try to dispatch { type: 'reset' } and watch your editor light up with red squiggles of shame. It’s a beautiful thing.

Lazy Initialization and the Function Initializer

Sometimes your initial state might be expensive to calculate. The useReducer hook accepts a third function form for this. The typing is straightforward, but it’s a common tripping point.

// A function that creates your initial state
function init(initialCount: number): State {
  return {
    count: initialCount,
    text: `The count starts at ${initialCount}`,
  };
}

function reducer(state: State, action: Action): State {
  // ... same reducer as before
}

function CounterComponent({ initialCount = 0 }) {
  // Pass the init function as the third argument
  const [state, dispatch] = useReducer(reducer, initialCount, init);
  // ... rest of the component
}

TypeScript ensures the argument passed to init matches the type of the second argument (initialCount: number), and that the return value of init matches State. It’s all connected, and the types flow through seamlessly.