目录
Part 1: What is a reducer in JavaScript?
Part 2: How to useReducer in React?
2.1. Reducer in React
2.2. React's useReducer Hook
Part 3: What about useState? Can’t we use that instead?
Part 1: What is a reducer in JavaScript?
The concept of a Reducer became popular in JavaScript with the rise of Redux as state management solution for React. But no worries, you don't need to learn Redux to understand Reducers. Basically reducers are there to manage state in an application. For instance, if a user writes something in an HTML input field, the application has to manage this UI state (e.g. controlled components).
代码语言:javascript复制(state, action) => newState
As example, it would look like the following in JavaScript for the scenario of increasing a number by one:
代码语言:javascript复制function counterReducer(state, action) {
return state 1;
}
Or defined as JavaScript arrow function, it would look the following way for the same logic:
代码语言:javascript复制const counterReducer = (state, action) => {
return state 1;
};
In this case, the current state is an integer (e.g. count) and the reducer function increases the count by one. If we would rename the argument state to count, it may be more readable and approachable by newcomers to this concept. However, keep in mind that the count is still the state:
代码语言:javascript复制const counterReducer = (count, action) => {
return count 1;
};
The reducer function is a pure function without any side-effects, which means that given the same input (e.g. state and action), the expected output (e.g. newState) will always be the same. This makes reducer functions the perfect fit for reasoning about state changes and testing them in isolation. You can repeat the same test with the same input as arguments and always expect the same output:
代码语言:javascript复制expect(counterReducer(0)).to.equal(1); // successful test
expect(counterReducer(0)).to.equal(1); // successful test
That's the essence of a reducer function. However, we didn't touch the second argument of a reducer yet: the action. The action is normally defined as an object with a type property. Based on the type of the action, the reducer can perform conditional state transitions:
代码语言:javascript复制const counterReducer = (count, action) => {
if (action.type === 'INCREASE') {
return count 1;
}
if (action.type === 'DECREASE') {
return count - 1;
}
return count;
};
If the action type doesn't match any condition, we return the unchanged state. Testing a reducer function with multiple state transitions -- given the same input, it will always return the same expected output -- still holds true as mentioned before which is demonstrated in the following test cases:
代码语言:javascript复制// successful tests
// because given the same input we can always expect the same output
expect(counterReducer(0, { type: 'INCREASE' })).to.equal(1);
expect(counterReducer(0, { type: 'INCREASE' })).to.equal(1);
// other state transition
expect(counterReducer(0, { type: 'DECREASE' })).to.equal(-1);
// if an unmatching action type is defined the current state is returned
expect(counterReducer(0, { type: 'UNMATCHING_ACTION' })).to.equal(0);
However, more likely you will see a switch case statement in favor of if else statements in order to map multiple state transitions for a reducer function. The following reducer performs the same logic as before but expressed with a switch case statement:
代码语言:javascript复制const counterReducer = (count, action) => {
switch (action.type) {
case 'INCREASE':
return count 1;
case 'DECREASE':
return count - 1;
default:
return count;
}
};
In this scenario, the count itself is the state on which we are applying our state changes upon by increasing or decreasing the count. However, often you will not have a JavaScript primitive (e.g. integer for count) as state, but a complex JavaScript object. For instance, the count could be one property of our state object:
代码语言:javascript复制const counterReducer = (state, action) => {
switch (action.type) {
case 'INCREASE':
return { ...state, count: state.count 1 };
case 'DECREASE':
return { ...state, count: state.count - 1 };
default:
return state;
}
};
Don't worry if you don't understand immediately what's happening in the code here. Foremost, there are two important things to understand in general:
- The state processed by a reducer function is immutable.That means the incoming state -- coming in as argument -- is never directly changed. Therefore the reducer function always has to return a new state object. If you haven't heard about immutability, you may want to check out the topic immutable data structures.
- Since we know about the state being a immutable data structure, we can use the JavaScript spread operator to create a new state object from the incoming state and the part we want to change (e.g. count property). This way we ensure that the other properties that aren't touch from the incoming state object are still kept intact for the new state object.
Let's see these two important points in code with another example where we want to change the last name of a person object with the following reducer function:
代码语言:javascript复制const personReducer = (person, action) => {
switch (action.type) {
case 'INCREASE_AGE':
return { ...person, age: person.age 1 };
case 'CHANGE_LASTNAME':
return { ...person, lastname: action.lastname };
default:
return person;
}
};
We could change the last name of a user the following way in a test environment:
代码语言:javascript复制const initialState = {
firstname: 'Liesa',
lastname: 'Huppertz',
age: 30,
};
const action = {
type: 'CHANGE_LASTNAME',
lastname: 'Wieruch',
};
const result = personReducer(initialState, action);
expect(result).to.equal({
firstname: 'Liesa',
lastname: 'Wieruch',
age: 30,
});
You have seen that by using the JavaScript spread operator in our reducer function, we use all the properties from the current state object for the new state object but override specific properties (e.g. lastname) for this new object. That's why you will often see the spread operator for keeping state operation immutable (= state is not changed directly).
Also you have seen another aspect of a reducer function: An action provided for a reducer function can have an optional payload (e.g. lastname) next to the mandatory action type property.The payload is additional information to perform the state transition. For instance, in our example the reducer wouldn't know the new last name of our person without the extra information.
Often the optional payload of an action is put into another generic payload property to keep the top-level of properties of an action object more general (.e.g { type, payload }). That's useful for having type and payload always separated side by side. For our previous code example, it would change the action into the following:
代码语言:javascript复制const action = {
type: 'CHANGE_LASTNAME',
payload: {
lastname: 'Wieruch',
},
};
The reducer function would have to change too, because it has to dive one level deeper into the action:
代码语言:javascript复制const personReducer = (person, action) => {
switch (action.type) {
case 'INCREASE_AGE':
return { ...person, age: person.age 1 };
case 'CHANGE_LASTNAME':
return { ...person, lastname: action.payload.lastname };
default:
return person;
}
};
Basically you have learned everything you need to know for reducers. They are used to perform state transitions from A to B with the help of actions that provide additional information. You can find reducer examples from this tutorial in this GitHub repository including tests. Here again everything in a nutshell:
- Syntax: In essence a reducer function is expressed as (state, action) => newState.
- Immutability: State is never changed directly. Instead the reducer always creates a new state.
- State Transitions: A reducer can have conditional state transitions.
- Action: A common action object comes with a mandatory type property and an optional payload:
- The type property chooses the conditional state transition.
- The action payload provides information for the state transition.
Part 2: How to useReducer in React?
Since React Hooks have been released, function components can use state and side-effects. There are two hooks that are used for modern state management in React: useState and useReducer. This tutorial goes step by step through a useReducer example in React for getting you started with this React Hook for state management.
2.1. Reducer in React
The following function is a reducer function for managing state transitions for a list of items:
代码语言:javascript复制const todoReducer = (state, action) => {
switch (action.type) {
case 'DO_TODO':
return state.map(todo => {
if (todo.id === action.id) {
return { ...todo, complete: true };
} else {
return todo;
}
});
case 'UNDO_TODO':
return state.map(todo => {
if (todo.id === action.id) {
return { ...todo, complete: false };
} else {
return todo;
}
});
default:
return state;
}
};
There are two types of actions for an equivalent of two state transitions. They are used to toggle the complete boolean to true or false of a todo item. As additional payload an identifier is needed which coming from the incoming action's payload.
The state which is managed in this reducer is an array of items:
代码语言:javascript复制const todos = [
{
id: 'a',
task: 'Learn React',
complete: false,
},
{
id: 'b',
task: 'Learn Firebase',
complete: false,
},
];
const action = {
type: 'DO_TODO',
id: 'a',
};
const newTodos = todoReducer(todos, action);
console.log(newTodos);
// [
// {
// id: 'a',
// task: 'Learn React',
// complete: true,
// },
// {
// id: 'b',
// task: 'Learn Firebase',
// complete: false,
// },
// ]
So far, everything demonstrated here is not related to React. If you have any difficulties to understand the reducer concept, please revisit the referenced tutorial from the beginning for Reducers in JavaScript. Now, let's dive into React's useReducer hook to integrate reducers in React step by step.
2.2. React's useReducer Hook
The useReducer hook is used for complex state and state transitions. It takes a reducer function and an initial state as input and returns the current state and a dispatch function as output with array destructuring:
代码语言:javascript复制const initialTodos = [
{
id: 'a',
task: 'Learn React',
complete: false,
},
{
id: 'b',
task: 'Learn Firebase',
complete: false,
},
];
const todoReducer = (state, action) => {
switch (action.type) {
case 'DO_TODO':
return state.map(todo => {
if (todo.id === action.id) {
return { ...todo, complete: true };
} else {
return todo;
}
});
case 'UNDO_TODO':
return state.map(todo => {
if (todo.id === action.id) {
return { ...todo, complete: false };
} else {
return todo;
}
});
default:
return state;
}
};
const [todos, dispatch] = useReducer(todoReducer, initialTodos);
The dispatch function can be used to send an action to the reducer which would implicitly change the current state:
代码语言:javascript复制const [todos, dispatch] = React.useReducer(todoReducer, initialTodos);
dispatch({ type: 'DO_TODO', id: 'a' });
The previous example wouldn't work without being executed in a React component, but it demonstrates how the state can be changed by dispatching an action. Let's see how this would look like in a React component. We will start with a React component rendering a list of items. Each item has a checkbox as controlled component:
代码语言:javascript复制import React from 'react';
const initialTodos = [
{
id: 'a',
task: 'Learn React',
complete: false,
},
{
id: 'b',
task: 'Learn Firebase',
complete: false,
},
];
const App = () => {
const handleChange = () => {};
return (
<ul>
{initialTodos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.complete}
onChange={handleChange}
/>
{todo.task}
</label>
</li>
))}
</ul>
);
};
export default App;
It's not possible to change the state of an item with the handler function yet. However, before we can do so, we need to make the list of items stateful by using them as initial state for our useReducer hook with the previously defined reducer function:
代码语言:javascript复制import React from 'react';
const initialTodos = [...];
const todoReducer = (state, action) => {
switch (action.type) {
case 'DO_TODO':
return state.map(todo => {
if (todo.id === action.id) {
return { ...todo, complete: true };
} else {
return todo;
}
});
case 'UNDO_TODO':
return state.map(todo => {
if (todo.id === action.id) {
return { ...todo, complete: false };
} else {
return todo;
}
});
default:
return state;
}
};
const App = () => {
const [todos, dispatch] = React.useReducer(
todoReducer,
initialTodos
);
const handleChange = () => {};
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
...
</li>
))}
</ul>
);
};
export default App;
Now we can use the handler to dispatch an action for our reducer function. Since we need the id
as the identifier of a todo item in order to toggle its complete
flag, we can pass the item within the handler function by using a encapsulating arrow function:
const App = () => {
const [todos, dispatch] = React.useReducer(
todoReducer,
initialTodos
);
const handleChange = todo => {
dispatch({ type: 'DO_TODO', id: todo.id });
};
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.complete}
onChange={() => handleChange(todo)}
/>
{todo.task}
</label>
</li>
))}
</ul>
);
};
This implementation works only one way though: Todo items can be completed, but the operation cannot be reversed by using our reducer's second state transition. Let's implement this behavior in our handler by checking whether a todo item is completed or not:
代码语言:javascript复制const App = () => {
const [todos, dispatch] = React.useReducer(
todoReducer,
initialTodos
);
const handleChange = todo => {
dispatch({
type: todo.complete ? 'UNDO_TODO' : 'DO_TODO',
id: todo.id,
});
};
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.complete}
onChange={() => handleChange(todo)}
/>
{todo.task}
</label>
</li>
))}
</ul>
);
};
Depending on the state of our todo item, the correct action is dispatched for our reducer function. Afterward, the React component is rendered again but using the new state from the useReducer hook.
React's useReducer hook is a powerful way to manage state in React. It can be used with useState and useContext for modern state management in React. Also, it is often used in favor of useState for complex state and state transitions. After all, the useReducer hook hits the sweet spot for middle sized applications that don't need Redux for React yet.
Part 3: What about useState? Can’t we use that instead?
An astute reader may have been asking this all along. I mean, setState is generally the same thing, right? Return a stateful value and a function to re-render a component with that new value.
代码语言:javascript复制const [state, setState] = useState(initialState);
We could have even used the useState() hook in the counter example provided by the React docs. However, useReducer is preferred in cases where state has to go through complicated transitions. Kent C. Dodds wrote up a explanation of the differences between the two and (while he often reaches for setState) he provides a good use case for using useReducer instead:
- When it's just an independent element of state you're managing: useState
- When one element of your state relies on the value of another element of your state in order to update: useReducer
My rule of thumb is to reach for useReducer to handle complex states, particularly where the initial state is based on the state of other elements.
参考:
What is a Reducer in JavaScript/React/Redux? https://www.robinwieruch.de/javascript-reducer/ How to useReducer in React: https://www.robinwieruch.de/react-usereducer-hook Getting to Know the useReducer React Hook: https://css-tricks.com/getting-to-know-the-usereducer-react-hook/ Should I useState or useReducer? https://kentcdodds.com/blog/should-i-usestate-or-usereducer