When I first started using React, updating state seemed like a very straightforward task. And for the most part, it is. Sometimes I saw people passing in functions to setState()
, and other times direct values. I didn't really think much of it until I eventually ran into a situation where my state was not updating the way I anticipated.
Let's create a simple Counter component to use as an example:
const Counter = () => {
const [count, setCount] = React.useState(0);
const increment = () => {
setCount(count + 1); };
return (
<>
<div>Count: {count}</div>
<button onClick={increment}>Increment</button>
</>
);
};
Pay attention to the expression count + 1
. We are grabbing the current value of count from our state and adding 1 to it. That expression is evaluated and the value is passed as an argument to setCount()
.
For a simple component like this, you won't run into any problems passing state values directly into your useState()
calls. However, as your React applications become more complex, you may run into issues where using this method will cause your state to behave in unexpected ways. You may run into stale state values, or state that doesn't appear to update at all.
Thankfully, there is a way that we can bypass all of these issues.
Functional Updates
Let's update our handler so it uses a functional update with setCount()
instead of a value:
const increment = () => {
setCount((prevCount) => prevCount + 1);};
When we pass in a function to setCount()
, we are given a parameter that contains the previous state (in this case we've named it prevCount
). In much the same way as before, we're simply adding 1 to that value and returning it implicitly. Finally, setCount()
updates the state with that value.
At first glance, this functional update seems to be accomplishing the same thing as our first example. The only difference is we're using a function instead of directly accessing the count state. If you test it yourself, you'll see the both examples the same thing.
Okay, so what's the difference?
In order to illustrate why functional updates are valuable, let's change our example up a bit:
const Counter = () => {
const [count, setCount] = React.useState(0);
const increment = () => {
setCount((prevCount) => prevCount + 1);
};
const incrementWithTimeout = () => { setTimeout(() => { setCount(count + 1); }, 2000); };
return (
<>
<div>Count: {count}</div>
<button onClick={increment}>Increment</button>
<button onClick={incrementWithTimeout}>Increment (Timeout)</button>
</>
);
};
Now we've added a new incrementWithTimeout()
handler. We're wrapping our setCount()
update in a setTimeout()
function. Go ahead and give this example a try.
Try clicking the Increment (Timeout) button and then spam clicking the Increment button. You'll notice the count resets back to whatever it was when you originally clicked the Increment (Timeout) button. All the state updates that happened after you clicked it were lost. How can we fix this?
You guessed it: Functional state updates! Let's update our incrementWithTimeout()
handler by changing it to use a functional update instead:
const incrementWithTimeout = () => {
setTimeout(() => {
setCount((prevCount) => prevCount + 1); }, 2000);
};
We've replaced our count + 1
expression with (prevCount) => prevCount + 1
. If you try this updated example, the count is now maintained even if you spam click the Increment button after clicking the Increment (Timeout) button.
What is going on?
The simple explanation is that React's setState()
is an asynchronous operation. According to the React documentation:
React may batch multiple
setState()
calls into a single update for performance.
This means that by using a direct reference to count, we may be getting a stale value when that batched state update is finally run by React.
In the case of our first incrementWithTimeout()
handler, the value of count is closed over at that point in time and sent to the queue of state updates for React to perform. When React finally runs the update, we're accessing a stale value of count. Switching to a functional update will get the current value of count the moment React actually performs the state update, bypassing the problem.
A general rule of thumb is that if your next state value depends on the previous state value, or you are performing a state update after some sort of asynchronous action, you should try to use a functional state update. Ever since I learned about this, I always try to use functional state updates whenever possible to avoid these pitfalls.
See anything wrong? This blog is a learning experience for me and I would love to correct any errors you might find. Message me on Twitter and I'll update my post!