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
.
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.
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
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.
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:
1function handleAdd() {
2 setCount(count + 1);
3}
With dispatch()
, it can be rewritten as:
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
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.
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,
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
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
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:
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.