Why functional state updates are important

Today, we're going to look at why functional state updates are important in React, talk about when you should use one, and discuss some of the issues you may encounter if you don't.

Table of contents

What is a functional state update?

A functional state update is one in which you use the callback from setState to retrieve the most up-to-date state value and looks something like this:

1setCount((previousCount) => previousCount + 1);

The setter function from the useState hook acts asynchronously, meaning that if you change a piece of state and immediately try to log it from within the same closure, you can get unwanted behaviour.

You have probably come across this before and it looks something like this:

1const [count, setCount] = useState(0);
2
3const handleClick = () => {
4 setCount(count + 1);
5
6 console.log(count); // 0
7};

Note: Functional state updates don't change the behavior in the example above. It is just a demonstration of how setState can act asynchronously.

Note how by referencing count here, we get a stale value.

This demonstrates how setState acts asynchronously and how it can be dangerous to access state values like this.

The setState callback gives us a reliable, stable reference to the current (also known as previous) state.

When should I use one?

You should consider using a functional state update when you rely on existing state to make an update.

Let's look at an example where we should use a functional state update.

1const [count, setCount] = useState(0);
2
3const handleClick = () => {
4 setCount(count + 1); // We are relying on previous state.
5};

Note how we are computing our new state value based on the current/previous state value. This is an example of where we should use a functional state update to ensure a reliable reference to our count (more on this later).

Now, here is an example where you do not need to use a functional update as the previous state value is irrelevant.

1const [name, setName] = useState("Louis");
2
3const handleClick = () => {
4 setName("John"); // We are not relying on previous state.
5};

What if I don't use one?

If you make updates to state when relying on previous state values without using a functional state update, you can introduce race conditions and silent bugs into your application.

You can often run into issues when state is updated again before a render happens.

This can result in stale state which we'll demonstrate below.

Examples

Let's refer to the previous counter example and introduce a bug by not using a functional state update.

1const [count, setCount] = useState(0);
2
3const handleClick = () => {
4 setCount(count + 1);
5
6 setCount(count + 1);
7};

Note: Please remember that this is a contrived example solely for the purposes of this article.

Here you can see that we update state again before a render happens without using a functional update which results in a stale reference to count for our second update.

When we click the button to increment our count, it only increments by one:

0

We could resolve this by using functional state updates:

1const [count, setCount] = useState(0);
2
3const handleClick = () => {
4 setCount((previousCount) => previousCount + 1);
5
6 setCount((previousCount) => previousCount + 1);
7};

Now, as we use the functional update, we refer to the up-to-date state value and our button increments the count by two:

0

Something to think about

If you find yourself using multiple setState updates and have multiple pieces of state that rely on each other, consider combining them by using a reducer.

Conclusion

The examples I used are very low level and highly contrived, but hopefully they convey the concepts we covered clearly and make them easy to understand.

Hopefully you enjoyed this article and you learnt a thing or two about state updates.