react emote

React 18.x & TypeScript

How to safely type the useReducer hook

Use TypeScript to strongly type your code

2022-12-29
7 min read
Difficulty
react
typescript
react emote

Introduction

The React useReducer hook is one of the less used and under rated built-in hook but it can be especially useful when we use several useState hooks in a component that are related to each other.

The concept is very simple:

  • the state is handled by a (reducer) function
  • you need to dispatch an action to update the state
  • the function is automatically invoked after every dispatch and the state is updated accordingly

How to use useReducer

The useReducer hook accepts at least two parameters:

  • a reducer function
  • the initial state

and returns the current state and the dispatch function:

JavaScript
const [state, dispatch] = useReducer(reducerFn, initialState);

New to 'useReducer'?

If you have never used the useReducer hook before I highly recommend you to read my article

 React: from useState hook to useReducer

Topics

Type

The first step is the creation of a custom type to represents the state:

TypeScript
interface AppState {
  counter: number; // a simple counter
  random: number;  // a random value
}

Actions

You can emit an action by using the dispatch function returned by the useReducer hook (we'll discuss about it later in this post).

An "action" can be a simple string as shown below:

TypeScript
dispatch('increment')

But, by convention, it's common to pass an object with the type property and, if necessary, a payload:

TypeScript
dispatch({type: 'increment', payload: 10})

Action Type

The previous action can be represented by the following type:

TypeScript
type AppAction = {
  type: string;
  payload: number;
}

Or we can use literal types in order to define exactly what string we should pass:

TypeScript
type AppAction = {
  type: 'increment';
  payload: number;
}

Since you may need to handle several actions in order to update the same state, you can use the Union type to define what types of actions can be accepted by the type property:

TypeScript
type AppActions = {
  type: 'increment' | 'decrement';
  payload: number;
}

But how can we handle the scenario where each action has a different payload (i.e. a string and a number) or doesn't have it at all?

In fact our state is composed by:

  • counter: updated when the increment action is emitted. This action accepts a payload (number) that is the value to add to the current counter.
  • random : updated when the random action is emitted and has no payload

We may think to update our type to the following:

TypeScript
type AppActions = {
  type: 'increment' | 'decrement' | 'random';
  payload?: number; // ? means "not required"
}

However, since we have used the question mark ?, the payload is optional, and we'll never know if it's really required or not when we'll dispatch an action. Furthermore, things get even worse if we add a new action that required a string as payload.

What's the solution?

We can create a different type for each action and use the Union type again:

TypeScript
type Increment = { type: 'increment'; payload: number };
type Random = { type: 'random' };

type AppActions = Increment | Random;

Reducer

The reducer is a simple function that is automatically invoked each time you dispatch a new action. This function always receives two parameters, the current state and the dispatched action, and returns the new state.

As you can see in the script below, the state is typed as AppState and the action as AppActions:

TypeScript
function appReducer(state: AppState, action: AppActions) {
  switch (action.type) {
    case 'increment':
      return { ...state, counter: state.counter + action.payload };
    case 'random':
      return { ...state, random: Math.random() };
    default:
      return state;
  }
}

Since our code is strongly typed you cannot use any string in the case statement, but increment and random are the only accepted values.

The switch statement and TypeScript Guards allow you to narrowing types. In fact, the action.payload is of type number when the action is increment, while there is no payload if the action is random.

Check in your editor

Move your mouse over the payload property in your favorite editor to check the payload type:

You can also create a new action with a string as payload and check if it works.

Usage

That's all. You can now safely use the useReducer hook in your components and custom hooks.

ReactTypeScript
const [state, dispatch] = useReducer(appReducer, { counter: 0, random: 0 });

And now you can dispatch the actions:

ReactTypeScript
<button onClick={() => dispatch({ type: 'increment', payload: 10 })}>
<button onClick={() => dispatch({ type: 'random' })}>

Since our code is strongly typed you cannot pass any string anymore to the type property but increment and random are the only accepted values.

Furthermore, the increment action requires a payload of type number while the random action does not.

Here an example of a React component that uses the useReducer hook, dispatch actions and display the state:

ReactTypeScript
App.tsx
export default function App() {
  const [state, dispatch] = useReducer(appReducer, { counter: 0, random: 0 });

  return (
    <div>
      <button onClick={() => dispatch({ type: 'increment', payload: 10 })}>
        "Increment" Action
      </button>
      <button onClick={() => dispatch({ type: 'random' })}>
        "Random" Action
      </button>

      <div>{state.counter}</div>
      <div>{state.random}</div>
    </div>
  );
}

And of course you can also pass the state to children by drilling props, composition and Context:

ReactTypeScript
<Child1 value={state.counter} />
<Child2 value={state.random} />

Code Playground

The completed example is available on StackBlitz

For simplicity I wrote all the code in App.tsx but, of course, you can (you should! 😅) split it in several files

Video Tutorial

I have also recorded a video tutorial about this topic available on my YouTube Channel:

Keep updated about latest content
videos, articles, tips and news