When do I need to pass a function to a useState setter function? I searched everywhere, but I couldn’t find any proper explanation. Especially when to use one form over another.

I used react js for over a year until I learned about the functional updater form. And did not see any issues with my code from not using it. But there needs to be a reason why it is there, And when you should use it. Let’s find out together.

The react documentation states:

If the new state is computed using the previous state, you can pass a function to setState.

But why do you need to do this? And why do you find so many examples out there where the old state is accessed by the “state” variable and it looks like it is working correctly? Obviously, the examples can’t all be wrong!

The counter example

When you search for this topic you soon stumble over the following example:

function  Counter() {
  const [count, setCount] = useState(0);
  
    const update = () => {
        setCount(count + 1);
    };
    
    return (<div>
        <span>{count}</span>
        <ul>
            <li><a onClick={update}>Count</a></li>
        </ul>
    </div>);
}

I don’t really like this example as this works correctly and the problem only is visible when you add a second call to setCount inside the updatefunction like so:

function  Counter() {
  const [count, setCount] = useState(0);
  
    const update = () => {
        setCount(count + 1); // count = 0
        setCount(count + 1); // our second call to increment count; but count is still 0 here!
    };
    
    return (<div>
        <span>{count}</span>
        <ul>
            <li><a onClick={update}>Count</a></li>
        </ul>
    </div>);
}

But the example never really explains how you would get two separate setCount in actual projects. And when there is a try for an explanation, we get something like when you use complicated code. But there is never an explanation of what this means.

The reason the above code does not work right is that the call to setCount does the following two things:

  1. Batches an update for the state
  2. Triggers a rerender of the component

The actual state update happens later when the component is rerendered. So when we return from the first setCount call the count variable still contains our original value of 0. And the second setCount call just batches the same state change again. Our count variable is a regular JavaScript variable. And the update method has access to it due to how JavaScript handles scoping with nested functions. You can read this article for more details about how this works: Closures.

So conceptually, we can say that each time we want to update our state and depend on the current state, we want to use the updater function variant. So we are guaranteed to get the correct value in all cases.

The same example using a callback as argument to the setCount function.

function  Counter() {
  const [count, setCount] = useState(0);
  
    const update = () => {
        setCount((currentCount) => currentCount + 1); // currentCount = 0
        setCount((currentCount) => currentCount + 1); // currentCount = 1
    };
    
    return (<div>
        <span>{count}</span>
        <ul>
            <li><a onClick={update}>Count</a></li>
        </ul>
    </div>);
}

In this version, we access the up-to-date state of the count variable as it is given as the first argument to the callback. And we never miscount our counter.

But knowing this now, what could be a better real-world example be for such a case?

A more realistic example

This example better shows how you can see this issue in an actual codebase. This example still has some issues, but we ignore them for this article1.

function  Counter() {
  const [count, setCount] = useState(0);
  
    const update = async () => {
        await doSomeWork(); // assume this method takes some time > 500ms
        setCount(count + 1);
    };
    
    return (<div>
        <span>{count}</span>
        <ul>
            <li><a onClick={update}>Count</a></li>
        </ul>
    </div>);
}

It also highlights why the bugs you can get with this problem are pretty hard to debug and track down as they are dependent on the click rate of your users and on the execution time of the async function2.

With the await keyword, we instruct the JavaScript interpreter to wait until the work in the doSomeWork method is over. It means that we only execute the setCount call after 500 ms. And when the user is impatient and presses the “Count” button multiple times in a short time, she can “queue” multiple executions of this updater function before the first call of doSomeWork is ever done. And as we have seen before, each of these queued calls references the old state of the count variable, causing this code to miscount the clicks.

And it really could be the case that you don’t see any problem in your dev environment as you don’t use the real external system for whatever doSomeWork would do, and your mock is always fast. Effectively hiding this issue until the day you release.

Conclusion

And here, we see why we need to use the functional updates syntax when our state update depends on the current state. In most cases, it would be acceptable to use the simpler variant. But when your code gets a bit complex and has asynchronous side effects, you can run into these issues. And for that reason, it is a good rule always to use functional update syntax when you access your current state.

Did you ever use functional updates in your code? If not, check your code for a location where you access the current state within a set method and change it over to the new syntax.

I hope this post provided you with value and you liked reading it. I would appreciate it if you signed up for my email list never to miss another post.


  1. You would not want to have such a side effect in a method where the user can fire it multiple times without realizing it.
    Just imaging this doSomeWork function triggers a payment. Now the user has paid multiple times. You would need to track some loading states and disable the update function after the first click in the real world. ↩︎

  2. We now have an update funcion which is long running and asychronous. An example implementation of this method can look like this:

    const doSomeWork = () => new Promise((resolve) => setTimeout(resolve, 500));

    In reality it could be some other async task like a network request. ↩︎