Set state callbacks in React, a counter example

In React, we can set state in a functional component, e.g.
const [myState, setMyState] = useState("");
setMyState("myValue");
useState is a React hook that returns a pair of values: the current state
myState and a function setMyState that when called updates the current state
myState.
In the snippet above, we pass the string "myValue" to setMyState which
updates the value of myState from "" to "myValue".
We passed in a string and could have equally passed in an integer, object, array, etc.
However, we can also pass into setMyState a function.
This function accepts one argument, the current value of the state we are about to update, and it returns the new value of the state.
Why pass in a callback?
At first glance, this seems an unnecessary complication, replacing
setMyState("myValue");
with
setMyState((prevMyState) => "myValue");
What if the new state depends on the current state? E.g.
setMyState((prevMyState) => prevMyState + "myValue");
Well, in that case
setMyState(myState + "myValue");
However, the reason for this seemingly unnecessary complication is React updates state asynchronously.
If we call setMyState and then call it again, strictly speaking there is no
guarantee when you call setMyState the second time that the first update has
completed.
In reality, usually this is not a problem and passing in a value rather than a callback behaves as expected.
However, as we will see in the example below, this is not always the case.
A counter example
Suppose we have a button and a counter displayed to the user. Each time the button is clicked, the counter increases, e.g.
export const App = () => {
const initialCounter = 0;
const [counter, setCounter] = React.useState(initialCounter);
const handleAdd = () => {
setCounter(counter + 1);
};
const handleReset = () => {
setCounter(initialCounter);
};
const style = {
padding: 16,
};
return (
<>
<div style={style}>Counter: {counter}</div>
<div style={style}>
<button onClick={handleAdd}>Add</button>
</div>
<div style={style}>
<button onClick={handleReset}>Reset</button>
</div>
</>
);
};
The above works as expected: if we click the button twice, 2 is displayed; if
we click the button three times, 3 is displayed, etc.
However, let’s add a delay to handleAdd, i.e.
const handleAdd = () => {
setTimeout(() => setCounter(counter + 1), 2000);
};

Now, the counter displays 1 instead of 2 even though the button is clicked
twice.
What went wrong?
Because the state is updated asynchronously, the first call of setCounter sees
the value of counter at the time of the first click, and the second call of
setCounter sees the value of counter at the time of the second click.
At both the time of the first click and the second click, counter has a value
of zero. Thus each call of setCounter increases the value of counter from
zero to one which is why we see 1 displayed.
The fix
Pass in a callback to setCounter instead of a value, i.e.
const handleAdd = () => {
setTimeout(() => setCounter((prevCounter) => prevCounter + 1), 2000);
};

The button is clicked seven times, and the correct value of 7 is displayed!
Class based components
For completeness, below is the equivalent code using class based components.
With bug
export class App extends React.Component {
constructor(props) {
super(props);
this.initialCounter = 0;
this.state = { counter: this.initialCounter };
}
render() {
const handleAdd = () => {
const newCounter = this.state.counter + 1;
setTimeout(() => this.setState({ counter: newCounter }), 2000);
};
const handleReset = () => {
this.setState({ counter: this.initialCounter });
};
const style = {
padding: 16,
};
return (
<>
<div style={style}>Counter: {this.state.counter}</div>
<div style={style}>
<button onClick={handleAdd}>Add</button>
</div>
<div style={style}>
<button onClick={handleReset}>Reset</button>
</div>
</>
);
}
}
Without bug
export class App extends React.Component {
constructor(props) {
super(props);
this.initialCounter = 0;
this.state = { counter: this.initialCounter };
}
render() {
const handleAdd = () => {
setTimeout(
() =>
this.setState((prevState) => {
return {
counter: prevState.counter + 1,
};
}),
2000
);
};
const handleReset = () => {
this.setState({ counter: this.initialCounter });
};
const style = {
padding: 16,
};
return (
<>
<div style={style}>Counter: {this.state.counter}</div>
<div style={style}>
<button onClick={handleAdd}>Add</button>
</div>
<div style={style}>
<button onClick={handleReset}>Reset</button>
</div>
</>
);
}
}