6.1 - Extracting State Logic into a Reducer
Components with many state updates spread across many event handlers can get overwhelming. Reducers consolidate state update logic into a single function outside your component.
Consolidate state logic with a reducer
As your components grow, your state update logic's readability can decrease. For example, let's say we have a TaskApp
component. It holds an array of tasks in state and uses three different event handlers to add
, remove
, and edit
tasks:
import { useState } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
export default function TaskApp() {
const [tasks, setTasks] = useState(initialTasks);
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}
function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}
return (
<>
<h1>Prague itinerary</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
let nextId = 3;
const initialTasks = [
{id: 0, text: 'Visit Kafka Museum', done: true},
{id: 1, text: 'Watch a puppet show', done: false},
{id: 2, text: 'Lennon Wall pic', done: false},
];
Each event handler calls setTasks
in order to update the state.
As this component grows, so does the amount of state logic sprinkled throughout it.
We can move our state logic into a single function outside your component, called a “reducer”.
Reducers are just a different way to handle state. You can migrate from useState
to useReducer
in three steps:
- Move from setting state to dispatching actions.
- Write a reducer function.
- Use the reducer from your component.
Step 1: Move from setting state to dispatching actions
The three event handlers above can be boiled down to:
handleAddTask(text)
is called when the user presses “Add”.handleChangeTask(task)
is called when the user toggles a task or presses “Save”.handleDeleteTask(taskId)
is called when the user presses “Delete”.
Managing state with reducers is slightly different from directly setting state. Instead of telling React “what to do” by setting state, you specify “what the user just did” by dispatching “actions” from your event handlers. (The state update logic will live elsewhere!)
So instead of “setting tasks” via an event handler, you’re dispatching an “added/changed/deleted a task” action. This is more descriptive of the user’s intent.
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch(
// "action" object
{
type: 'deleted',
id: taskId,
});
}
The object you pass to dispatch
is called an “action”.
dispatch(
// "action" object:
{
type: 'what_happened', // useful description
id: taskId, // other fields go here
}
);
It is a regular JavaScript object. You can put any fields you’d like in there, but the convention is to give it a string type that describes what happened. (You will add the dispatch function itself in a later step.)
Write a reducer function
A reducer function is where you will put your state logic. It takes two arguments, the current state and the action object, and it returns the next state:
function yourReducer(state, action) {
// return next state for React to set
}
React will set the state to what you return from the reducer.
To move your state setting logic from your event handlers to a reducer function in this example, you will:
- Declare the current state (tasks) as the first argument.
- Declare the action object as the second argument.
- Return the next state from the reducer (which React will set the state to).
Here is all the state setting logic migrated to a reducer function:
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case 'changed': {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
The code above could also be written using if/else statements, but it’s a convention to use switch statements inside reducers. The result is the same, but it can be easier to read switch statements at a glance. Also, a case should usually end with a return
. If you forget to return, the code will “fall through” to the next case, which can lead to mistakes!
Step 3: Use the reducer from your component
Finally, you need to hook up the tasksReducer
to your component. Import the useReducer
Hook from React:
import { useReducer } from 'react';
Then you can replace useState
with with useReducer
like so:
const [tasks, setTasks] = useState(initialTasks); // delete this
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); // and use this instead!
The useReducer
Hook is similar to useState
—you must pass it an initial state and it returns a stateful value and a way to set state (in this case, the dispatch
function). But it’s a little different.
The useReducer
Hook takes two arguments:
- A reducer function
- An initial state
And it returns:
- A stateful value
- A
dispatch
function (to “dispatch” user actions to the reducer)
If you want, you can even move the reducer to a different file:
- App.js
- tasksReducer.js
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import tasksReducer from './tasksReducer.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
return (
<>
<h1>Prague itinerary</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
let nextId = 3;
const initialTasks = [
{id: 0, text: 'Visit Kafka Museum', done: true},
{id: 1, text: 'Watch a puppet show', done: false},
{id: 2, text: 'Lennon Wall pic', done: false},
];
export default function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case 'changed': {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
Component logic can be easier to read when you separate concerns like this. Now the event handlers only specify what happened by dispatching actions, and the reducer function determines how the state updates in response to them.