Using useReducer
In this section, we will learn how to use useReducer
. We will learn about its typical usage, how to bail out, its usage with primitive values, and lazy initialization.
Typical usage
A reducer is helpful for complex states. Here's a simple example a with two-property object:
const reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'SET_TEXT':
return { ...state, text: action.text };
default:
throw new Error('unknown action type');
}
};
const Component = () => {
const [state, dispatch] = useReducer(
reducer,
{ count: 0, text: 'hi' },
);
return (
<div>
{state.count}
<button
onClick={() => dispatch({ type: 'INCREMENT' })}
>
Increment count
</button>
<input
value={state.text}
onChange={(e) =>
dispatch({ type: 'SET_TEXT', text: e.target.value })}
/>
</div>
);
};
useReducer
allows us to define a reducer function in advance by taking the defined reducer function and initial state in parameters. The benefit of defining a reducer function outside the hook is being able to separate code and testability. Because the reducer function is a pure function, it's easier to test its behavior.
Bailout
As well as useState
, bailout works with useReducer
too. Using the previous example, let's modify the reducer so that it will bail out if action.text
is empty, as follows:
const reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'SET_TEXT':
if (!action.text) {
// bail out
return state
}
return { ...state, text: action.text };
default:
throw new Error('unknown action type');
}
};
Notice that returning state
itself is important. If you return { ...state, text: action.text || state.text }
instead, it won't bail out because it's creating a new object.
Primitive value
useReducer
works for non-object values, which are primitive values such as numbers and strings. useReducer
with primitive values is still useful as we can define complex reducer logic outside it.
Here is a reducer example with a single number:
const reducer = (count, delta) => {
if (delta < 0) {
throw new Error('delta cannot be negative');
}
if (delta > 10) {
// too big, just ignore
return count
}
if (count < 100) {
// add bonus
return count + delta + 10
}
return count + delta
}
Notice that the action (= delta
) doesn't have to have an object either. In this reducer example, the state value is a number—a primitive value—but the logic is a little more complex, with more conditions than just adding numbers.
Lazy initialization (init)
useReducer
requires two parameters. The first is a reducer function and the second is an initial state. useReducer
accepts an optional third parameter, which is called init
, for lazy initialization.
For example, useReducer
can be used like this:
const init = (count) => ({ count, text: 'hi' });
const reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'SET_TEXT':
return { ...state, text: action.text };
default:
throw new Error('unknown action type');
}
};
const Component = () => {
const [state, dispatch] = useReducer(reducer, 0, init);
return (
<div>
{state.count}
<button
onClick={() => dispatch({ type: 'INCREMENT' })}
>
Increment count
</button>
<input
value={state.text}
onChange={(e) => dispatch({
type: 'SET_TEXT',
text: e.target.value,
})}
/>
</div>
);
};
The init
function is invoked just once on mount
, so it can include heavy computation. Unlike useState
, the init
function takes a second argument—initialArg
—in useReducer
, which is 0
in the previous example.
Now we have looked at useState
and useReducer
separately, it's time to compare them.