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.