Typed Reducer

I prefer useReducer to useState when I'm using React, it gives us a declarative (and as you'll see, strongly typed) way of defining our next state after an update. With useReducer() we have a tuple of state and how to update that state, the dispatch:

const [state, dispatch] = React.useReducer(reducer, initialState);

We have two concepts here: the state, a snapshot of what should be rendering inside your component and the dispatch, a way of describing how to update that state.

If you create a reducer function, you're going to need to create a pure function, meaning there's a single input and derived output, that returns your next state. When you add type annotations for your state object that's pretty clear most of the time, it's what you want the out to be each time:

interface IState {
  someKey: string;
}

function reducer(state: IState, action): IState {
  // return some state that satisfies that interface
  return { someKey: "..." };
}

Now there's some type safety, our compiler will warn when you're not returning that value that's an object that matches that interface. But adding type annotations to the second parameter action can get a little wild. Action is what we pass to the dispatch to update the state.

dispatch("foo");

// Then when the reducer is called
reducer(currentState, "foo");

You can pass anything, the pattern I've most often used is a switch statement and passing a { type: 'ActionName' }. When we create our reducer function we can call it a React.Reducer:

const reducer: React.Reducer<State, Action> = () => {};

We can pass generics to the reducer to describe the state and action.

interface IState {
  someKey: string;
}

interface IAction {
  type: string;
  payload?: any;
}

type MyReducer = React.Reducer<IState, IAction>;

Pitfalls of Typing Dispatch Functions

This tells us that when we call dispatch() we're expecting to call it like this:

// Both satisfy IAction for the compiler
dispatch({ type: "ActionOne", payload: [] }); // ✅
dispatch({ type: "ActionTwo" }); // ✅

Now this presents an issue, our payload might be undefined, or we might pass it into an action that doesn't need it, say our reducer looks like this:

interface IState {
  items: any[];
  loading: boolean;
}

interface IAction {
  type: string;
  payload?: any[];
}

const reducer: React.Reducer<IState, IAction> = (state, action) => {
  switch (action.type) {
    case "FETCHING": {
      return { ...state, loading: true };
    }
    case "ADD_ITEMS": {
      return { items: [...state.items, ...action.payload], loading: false };
    }
    default: {
      throw new Error("Must specify action type");
    }
  }
};

We can't spread our payload if it's undefined.

Federated Union Types

One thing I've found helps is using a federated union type. We need to change our IAction interface to a type.

type ActionType =
  | {
      type: "FETCHING";
    }
  | {
      type: "ADD_ITEMS";
      payload: any[];
    };

const reducer: React.Reducer<IState, ActionType> = () => {};

In that switch statement we don't get an error, we know when we call dispatch() with the type "ADD_ITEMS" we're expecting the payload to exist and be the type we specified.

Now when we call dispatch:

// Both will fail during compilation
dispatch({ type: "FETCHING", payload: [] }); // ❌
dispatch({ type: "ADD_ITEMS" }); // ❌

There you have it we've added more type safety to our reducer and we can even do a little better.

Enums for Action Types

It's a strong preference of mine when you're writing reducers in your application to use enums to define the action types. An enum in TypeScript is named constant, it's enumerable meaning it can be known at compile time. For our purposes it would allow us to specify a specific action type name with a specific dispatch:

enum NamedActions {
  FETCHING = "FETCHING",
  ADD_ITEMS = "ADD_ITEMS"
}

type ActionType =
  | {
      type: NamedActions.FETCHING;
    }
  | {
      type: NamedActions.ADD_ITEMS;
      payload: any[];
    };

dispatch({ type: NamedActions.FETCHING });

What this buys us is organization. It ensures the only thing calling dispatch() is coming from within our application and that it belongs to something we typed. With just simple strings, we lose that certainty.