Improving Code with useReducer

September 24, 2019


The next in our series of articles from the Second Edition of Learning React is about useReducer.

Consider the Checkbox component. This component is a perfect example of a component that holds simple state. The box is either checked or not checked. checked is the state value, and setChecked is a function that will be used to change the state. When the component first renders, the value of checked will be false:

function Checkbox() {
const [checked, setChecked] = useState(false);
return (
<>
<input
type="checkbox"
value={checked}
onChange={() => setChecked(checked => !checked)}
/>
{checked ? "checked" : "not checked"}
</>
);
}

This works well, but one area of this function could be cause for alarm:

onChange={() => setChecked(checked => !checked)}

Look at it closely. It feels ok at first glance, but are we stirring up trouble here? We're sending a function that takes in the current value of checked and returns the opposite, !checked. This is probably more complex than it needs to be. Developers could easily send the wrong information and break the whole thing. Instead of handling this way, why not provide a function as a toggle?

Let's add a function called toggle that will do the same thing: call setChecked and return the opposite of the current value of checked:

function Checkbox() {
const [checked, setChecked] = useState(false);
function toggle() {
setChecked(checked => !checked);
}
return (
<>
<input type="checkbox" value={checked} onChange={toggle} />
{checked ? "checked" : "not checked"}
</>
);
}

This is better. onChange is set to a predictable value: the toggle function. We know what that function is going to do every time, everywhere it is used. We can still take this one step further to yield even more predictable results each time we use the checkbox component. Remember the function that we sent to setChecked in the toggle function?

setChecked(checked => !checked);

We're going to refer to this function, checked => !checked, by a different name now: a reducer. A reducer function's most simple definition is that it takes in the current state and returns a new state. If checked is false, it should return the opposite, true. Instead of hardcoding this behavior into onChange events, we can abstract the logic into a reducer function that will always produce the same results. Instead of useState in the component, we'll use useReducer:

function Checkbox() {
const [checked, toggle] = useReducer(checked => !checked, false);
return (
<>
<input type="checkbox" value={checked} onChange={toggle} />
{checked ? "checked" : "not checked"}
</>
);
}

useReducer takes in the reducer function and the initial state, false. Then we'll set the onChange function to toggle which will call the reducer function.

Our earlier reducer checked => !checked is a prime example of this. If the same input is provided to a function, the same output should be expected. This concept originates with Array.reduce in JavaScript. reduce fundamentally does the same thing as a reducer: it takes in a function (to reduce all of the values into a single value) and an initial value and returns one value.

Array.reduce takes in a reducer function and an initial value. For each value in the numbers array, the reducer is called until one value is returned.

const numbers = [28, 34, 67, 68];
numbers.reduce((number, nextNumber) => number + nextNumber, 0); // 197

The reducer sent to Array.reduce takes in two arguments. You can also send multiple arguments to a reducer function:

function Numbers() {
const [number, setNumber] = useReducer(
(number, newNumber) => number + newNumber,
0
);
return <h1 onClick={() => setNumber(30)}>{number}</h1>;
}

Every time we click on the h1, we'll add 30 to the total each time.

useReducer to Handle Complex State

useReducer can help us handle state updates more predictably as state becomes more complex. Consider an object that contains user data:

const firstUser = {
id: "0391-3233-3201",
firstName: "Bill",
lastName: "Wilson",
city: "Missoula",
state: "Montana",
email: "bwilson@mtnwilsons.com",
admin: false
};

Then we have a component called User that sets the firstUser as the initial state, and the component displays the appropriate data:

function User() {
const [user, setUser] = useState(firstUser);
return (
<div>
<h1>
{user.firstName} {user.lastName} - {user.admin ? "Admin" : "User"}
</h1>
<p>Email: {user.email}</p>
<p>
Location: {user.city}, {user.state}
</p>
<button>Make Admin</button>
</div>
);
}

A common error when managing state is to overwrite the state:

<button
onClick={() => {
setUser({ admin: true });
}}
>
Make Admin
</button>

Doing this would overwrite state from firstUser and replace it with just what we sent to the setUser function: {admin: true}. This can be fixed by spreading the current values from user, and then overwriting the admin value:

<button
onClick={() => {
setUser({ ...user, admin: true });
}}
>
Make Admin
</button>

This will take the initial state and push in the new key/values: {admin: true}. We need to rewrite this logic in every onClick, making it prone to error. I might forget to do this when I come back to the app tomorrow.

function User() {
const [user, setUser] = useReducer(
(user, newDetails) => ({ ...user, ...newDetails }),
firstUser
);
...
}

Then send the new state value newDetails to the reducer, and it will be pushed into the object:

<button
onClick={() => {
setUser({ admin: true });
}}
>
Make Admin
</button>

This pattern is useful when state has multiple sub-values or when the next state depends on a previous state. Here we're tapping in to the power of the spread. Teach everyone to spread, they'll spread for a day. Teach everyone to useReducer, and they'll spread for life.