You Don’t Know useState Until You’ve Used Functional Updates

Tomasz Fiechowski on 2021-01-08

When we might need useState functional updates and how to use them

Finding the right tool is often half the battle. Photo by the author.

When we need to hold any state, useState is the most common solution in React nowadays. It returns the current state value and a method to update it:

const [state, setState] = useState(initialValue);

Most of the time, setState is used in a straightforward way when we call it with our desired state value passed as an argument: setState(newState). It’s simple and sufficient in most cases.

However, it’s not the only way to modify the state with setState. In the React documentation about useState, there is a paragraph explaining a functional way to update the state: functional updates.

Basically, they let you pass a function to setState instead of a value. That function will receive the current state as the first parameter and the returned value will become a new state, like this:

setState(currentState => {
  const newState = modify(currentState);
  return newState;
});

When Might I Need to Use Functional Updates?

If we are operating in a synchronous environment, most of the time, a regular setState will be sufficient. It starts to get complicated when multiple pieces of the application share the state and we have to work with asynchronous functions (e.g. when making API requests).

In other words, when we call an asynchronous function and reach the point of calling setState, other variables that we calculate a new state from might already be outdated.

Example

For the purpose of this article, let’s assume we’re building a simple counter. It will be incremented when buttons are clicked.

We will have two buttons: one just to increment the counter instantly and another that does that with some timeout (mimicking an API call going on, for example).

Counter we will be building

Implementation

Regular setState

Let’s start with just a simple, synchronous button to increase the counter:

simple-update.js

import { useState } from "react";

export function FunctionalUpdates() {
  const [counter, setCounter] = useState(0);

  function handleIncrement() {
    setCounter(counter + 1);
  }

  return (
    <div>
      <div>Counter value: {counter}</div>
      <button onClick={handleIncrement}>
        Increment
      </button>
    </div>
  );
}

It would work like this:

Simple counter.

Adding an asynchronous call

Now, let’s add the second button that will increment the counter asynchronously. We’ll use the following wait helper function to mimic an asynchronous function call that finishes after a specified period of time:

functional-updates-wait.js

export function wait({ miliseconds }) {
  return new Promise((resolve) => setTimeout(resolve, miliseconds));
}

Let’s make the increment function async, wait for some timeout, and then increment the counter as we did with the first button:

async-update.js

import { useState } from "react";
import { wait } from "./wait";

export function FunctionalUpdates() {
  const [counter, setCounter] = useState(0);

  function handleIncrement() {
    setCounter(counter + 1);
  }

  async function handleIncrementAsync() {
    await wait({ miliseconds: 2000 });
    setCounter(counter + 1);
  }

  return (
    <div>
      <div>Counter value: {counter}</div>
      <button onClick={handleIncrement}>Increment</button>
      <button onClick={handleIncrementAsync}>Increment asynchronously</button>
    </div>
  );
}

We made the function asynchronous, and we might think that this should do the job. But let’s check the outcome:

Counter with straightforward async increment

We increment the counter, then hit the asynchronous one, and increment it again a couple of times. Why is the final state of the counter 2?

When handleIncrementAsync was called, the counter value was 1. This value will be remembered for the whole lifecycle of the function. That’s why we get 2 at the end. When the await wait() finished, we still referenced the old value (1) and incremented that.

useCallback to the help — or not?

At first, we might think that useCallback would help to solve this situation. Putting counter as a dependency should make the function reference the most up-to-date value. Let’s try with the following code:

use-callback.js

import { useCallback, useState } from "react";
import { wait } from "./src/FunctionalUpdates/wait";

export function FunctionalUpdates() {
  const [counter, setCounter] = useState(0);

  function handleIncrement() {
    setCounter(counter + 1);
  }

  const handleIncrementCallback = useCallback(async () => {
    await wait({ miliseconds: 2000 });
    setCounter(counter + 1);
  }, [counter, setCounter]);

  return (
    <div>
      <div>Counter value: {counter}</div>
      <button onClick={handleIncrement}>
        Increment
      </button>
      <button onClick={handleIncrementCallback}>
        Increment useCallback
      </button>
    </div>
  );
}
Counter with useCallback-based async increment

The same situation still happens, even if counter is in the dependency array. Whenever it changes, the handleIncrementCallback is indeed updated, but only “new” function calls take leverage of this. If we have an already-running function (like we do since the first call to the “Increment useCallback” handler is still waiting for the wait to finish), it doesn’t make a difference.

Functional update

Let’s explore a different way to call setCounter now. As mentioned in the beginning, it can accept a function instead of a value. This function will be called by React and the current version of the state will be passed as an argument. This way is called a “functional update.” Let’s recap that:

setState(currentState => {
  const newState = modify(currentState);
  return newState;
});

The currentState will represent the actual value of the counter in our example. Let’s modify the code and use the functional update approach:

functional-update.js

import { useState } from "react";
import { wait } from "./wait";

export function FunctionalUpdates() {
  const [counter, setCounter] = useState(0);

  function handleIncrement() {
    setCounter(counter + 1);
  }

  async function handleIncrementFunctional() {
    await wait({ milisecomds: 2000 });
    setCounter(_counter => _counter + 1);
  }

  return (
    <div>
      <div>Counter value: {counter}</div>
      <button onClick={handleIncrement}>Increment</button>
      <button onClick={handleIncrementFunctional}>Increment functional</button>
    </div>
  );
}

We invoke setCounter like this: setCounter(_counter => _counter + 1). The _counter value will represent the most up-to-date value of the counter at the time when the setCounter is invoked. Let’s see the effect:

Counter with asynchronous functional updates

There are six clicks in total:

When wait finished, the counter value was 5. This value was passed to the functional update call. As an effect, we counted all the clicks correctly.

Little bonus

When we’re using functional updates, at first it makes sense to reuse the state variable name. For example:

const [counter, setCounter] = useState(0);
// ...
function handleIncrement() {
  setCounter(counter => counter + 1);
}

But it’s not a good practice to shadow (reuse) the variable from an outer scope. We may use _ or — even better — current to prefix the variable, but in my humble opinion, it still doesn’t look good enough:

setCounter(_counter => counter + 1);
setCounter(currentCounter => currentCounter + 1);

Personally, I’d suggest just extracting the increment operation to a separate function and passing it to setCounter:

function increment(value) {
  return value + 1;
}
// ...
setCounter(increment);

Summary

We’ve learned a different way to update a state using functional updates. We also highlighted why a similar approach with useCallback was not the right solution in this case.

Now, whenever you need multiple asynchronous functions to share a state, you have a solution to deal with this situation in a convenient way.

As usual, the whole project is in a GitHub repository.

Thanks for making it to the end of the article. Feel free to express your opinion in the comments. I’m very open and keen to hear your feedback, whether it’s positive or negative!