TypeOfNaN

Why you can't set state multiple times in a row in React

Nick Scialli
August 24, 2021

If you have ever tried to set a state variable multiple times in a row in a React component, you may have been surprised to find that it didn’t quite work.

First, let me clarify what I mean by “multiple times in a row.” We are going to use a very contived example—a counter:

function App() {
  const [count, setCount] = useState(0);

  const incrementTwice = () => {
    setCount(count + 1);
    setCount(count + 1);
  };

  return (
    <>
      {count}
      <br />
      <button onClick={incrementTwice}>Increment Twice</button>
    </>
  );
}

It would be reasonable to expect that, every time you click the “Increment Twice” button, count will increase by two. But it doesn’t! It just increments by 1. Every time you click. Deterministically.

So why is this?

Revisting fundamentals

Remember closures? Yep, that’s what’s going on here. Even though we’re in React-land, we’re ultimately still dealing with JavaScript. In our counter example, the incrementTwice function has closure over count and its value as of the current render. That value is 0 for both setCount calls. Think about it: how could the second setCount call possible know the future value of count?

So… we’re setting count to 0 + 1 and then we’re setting count to 0 + 1… whoops!

What about objects in state?

It works the same with objects in state. Let’s check out another contrived example:

function App() {
  const [person, setPerson] = useState({ name: 'Gerald', age: 19 });

  const incrementTwice = () => {
    setPerson({ ...person, age: person.age + 1 });
    setPerson({ ...person, age: person.age + 1 });
  };

  return (
    <>
      {person.age}
      <br />
      <button onClick={incrementTwice}>Increment Twice</button>
    </>
  );
}

Again, we have closure over the person object during the current render, which is an object with an age of 19. Even though we call setPerson the first time, it’s now creating a totally new object in memory that the second setPerson knows nothing about. The result: poor Gerald will only increment to 20 years old here.

Fixing the issue

Fixing the issue behind the issue

You probably just want the code to fix the problem, but I also think it’s important to recognize that this situation is probably a code smell—you can likely rewrite your code such that only one state update is necessary. In our contrived examples this is easy: you would just increment the count/age by two rather than one. In the situation you’re currently facing you can likely just do one state update, but it’s surely a bit harder to see how.

The actual code fix

Assuming you’re disinterested in my “code smell” discussion (who can blame you), the way to make sure you’re always referencing an up-to-date version of state in your state-setter is to use a callback function:

function App() {
  const [person, setPerson] = useState({ name: 'Gerald', age: 19 });

  const incrementTwice = () => {
    setPerson((p) => ({ ...p, age: p.age + 1 }));
    setPerson((p) => ({ ...p, age: p.age + 1 }));
  };

  return (
    <>
      {person.age}
      <br />
      <button onClick={incrementTwice}>Increment Twice</button>
    </>
  );
}

And this will increment Gerald’s age by two! What’s happening here is setPerson no longer has closure over person, but rather receives a function. React will go ahead and pass the current version of the person state—the first time with age 19 and the second time with age 20—to that callback function.

Conclusion

Even though React-land feels a bit magical, we’re still bound by the basic rules of JavaScript!

If you'd like to support this blog by buying me a coffee I'd really appreciate it!

Nick Scialli

Nick Scialli is a senior UI engineer at Microsoft.

© 2024 Nick Scialli