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!
Nick Scialli is a senior UI engineer at Microsoft.