What is a Reducer in React

As your component grows, so does the logic around the state variables. Each state could be associated with multiple event handlers, updating the state to different values. For example, here is a counter that could add, subtract, and reset the state count.

jsx
1import { useState } from "react";
2
3export default function Counter() {
4  const [count, setCount] = useState(0);
5
6  function handleAdd() {
7    setCount(count + 1);
8  }
9
10  function handleSubtract() {
11    setCount(count - 1);
12  }
13
14  function handleReset() {
15    setCount(0);
16  }
17
18  return (
19    <div>
20      <p>Count: {count}</p>
21      <button onClick={handleAdd}>Add</button>
22      <button onClick={handleSubtract}>Subtract</button>
23      <button onClick={handleReset}>Reset</button>
24    </div>
25  );
26}

In this example, there are three different event handlers, handleAdd(), handleSubtract(), and handleReset() tied to the state count. As the component grows in complexity, managing so many states and the various event handlers can get overwhelming.

In this case, you can merge all the state updating logic into one single function, referred to as the reducer, which can be placed outside of the component, and imported when needed.

text
1src
2├── App.jsx
3├── assets
4│   └── react.svg
5├── components
6│   └── Counter.jsx      <===
7├── index.css
8├── main.jsx
9└── reducers
10    └── countReducer.js  <===

Replacing useState with useReducer

To start using reducer, the first thing you should do is replacing useState() with useReducer() like this:

components/Counter.jsx

jsx
1import { useReducer } from "react";
2import countReducer from "../reducers/CountReducer";
3
4export default function Counter() {
5  const [count, dispatch] = useReducer(countReducer, 0);
6
7  . . .
8}

useReducer() is another React hook used to create states similar to useState(), except it accepts two arguments.

jsx
1const [count, dispatch] = useReducer(countReducer, 0);

The first argument is the reducer function, countReducer, which we are going to create later, and the second argument is the initial value for the state.

Unlike useState() which returns a setter function for the state, useReducer() returns a function dispatch().

Updating event handlers with dispatch

This dispatch() function allows you to specify "what the user did" instead of "what to do with the state", which usually involves more complex logic. For example, originally the handleAdd() event handler looks like this:

js
1function handleAdd() {
2  setCount(count + 1);
3}

With dispatch(), it can be rewritten as:

js
1function handleAdd() {
2  dispatch({
3    type: "add",
4  });
5}

You only need to define the type of action that will be dispatched, and useReducer() will automatically match it with the corresponding logic defined in countReducer().

Extracting logic to reducer

Inside the reducer function, all the logic related to the state count can be aggregated into one single function like this:

reducers/CountReducer.js

js
1export default function countReducer(count, action) {
2  if (action.type === "add") {
3    return count + 1;
4  } else if (action.type === "subtract") {
5    return count - 1;
6  } else if (action.type === "reset") {
7    return 0;
8  } else {
9    throw Error("Unknown action: " + action.type);
10  }
11}

When the event handler is triggered, the argument of dispatch() will be forwarded to countReducer() as the action object.

Also notice that we are not using a setter function when updating the state. When using a reducer, the value returned by countReducer() will be used to update count, and a new render will be registered just like using a setter function.

We are using if else statements in the above example, but in practice, it is conventional to use switch statements instead, which makes the syntax easier to read.

js
1export default function countReducer(count, action) {
2  switch (action.type) {
3    case "add":
4      return count + 1;
5    case "subtract":
6      return count - 1;
7    case "reset":
8      return 0;
9    default:
10      throw new Error("Unknown action: " + action.type);
11  }
12}

Passing extra data through dispatch

It is also possible to pass extra information inside the action object. For example,

js
1function handleAdd() {
2  dispatch({
3    type: "add",
4    num: 2,
5  });
6}

The property num will be passed to the countReducer(), which can be accessed through action.num. And now, every time handleAdd() is triggered, count will increment by 2.

components/Counter.jsx

jsx
1import { useReducer } from "react";
2import countReducer from "../reducers/CountReducer";
3
4export default function Counter() {
5  const [count, dispatch] = useReducer(countReducer, 0);
6
7  function handleAdd() {
8    dispatch({
9      type: "add",
10      num: 2,
11    });
12  }
13  function handleSubtract() {
14    dispatch({
15      type: "subtract",
16      num: 3,
17    });
18  }
19  function handleReset() {
20    dispatch({
21      type: "reset",
22    });
23  }
24
25  return (
26    <div>
27      <p>Count: {count}</p>
28      <button onClick={handleAdd}>Add 2</button>
29      <button onClick={handleSubtract}>Subtract 3</button>
30      <button onClick={handleReset}>Reset</button>
31    </div>
32  );
33}

reducers/countReducer.js

js
1export default function countReducer(count, action) {
2  if (action.type === "add") {
3    return count + action.num;
4  } else if (action.type === "subtract") {
5    return count - action.num;
6  } else if (action.type === "reset") {
7    return 0;
8  } else {
9    throw Error("Unknown action: " + action.type);
10  }
11}

useReducer vs useState

Take another look at our example reducer, seems like it has only made things more complicated than necessary. So why are we using reducers at all?

To make things easier to understand, the example event handlers in this lesson are relatively simple:

js
1function handleAdd() {
2  setCount(count + 1);
3}

But imagine the state update logic is more complicated, and there are multiple event handlers around each state. Things could easily get out of hand. useReducer can help you separate the state updating logic from the component.

However, it is generally recommended to start by using useState when the logic is simple, as it is easier to read and write. You should only consider moving to reducers when the component gets too complex.