Edit: This issue was caused by React.StrictMode
, but I am still unsure as to what the proper solution for my underlying problem should be. I need to perform an action with side effects based on a state change cause by an event, but I need more context than just the new value to decide if the side effect should happen, and the context only exists within the event handler.
I could not find any mention of this peculiar behaviour on Stack Overflow, but it seems like something many people must have encountered before.
Problem
When modifying a state created using the useState
hook, calling fetch
or alert
from inside the setState
callback results in these functions being called more than once, while everything else in the callback is executed just once.
At first, I thought the event was being fired repeatedly, but that is clearly not the case, as demonstrated by the surrounding console.log
calls, all of which will only be called once. Then, I thought maybe the callback was being call repeatedly, but even that cannot be true, once again proven by the surrounding console.log
calls.
Motivation
I understand that this is most likely an illegal use of the setState
, but I am now curious about what causes this strange behaviour.
The reason why I wrote my code this way was because I needed the new state value in an API request that was sent under certain conditions.
I read that it is best to use the callback version of setState
when modifying the state based on its previous value, so that is what I did. However, I also needed to make the API request with the new value, and I could not simply perform the same operation on the current state value outside the callback because it could not be guaranteed that it would be the same value that the callback was going to generate (or at least that is what I read about setState
).
Here is a SO answer suggesting my approach, but they do not explicitly mention that the setState
callback was a suitable place for side effects. And here is a SO answer with a possible solution/workaround to my problem.
I suppose I could use useEffect
with a dependency set to the state variable to achieve the same result, but I need more than just the new value; I also need to know what conditions have caused the value to change, which makes things more complicated than the example in the linked question.
Example
This is the App.tsx
file. The rest was generated using Create React App (npm init react-app my-app
) and has not been modified.
When you load the page and press just a
or b
for the first time, everything seems fine, but if you type anything else before pressing a
or b
, the fetch
or alert
functions, respectively, are called (at least) twice.
import React, { useState } from "react";
import "./App.css";
function App() {
const [typedText, setTypedText] = useState<string>("");
function keyDownHandler(event: React.KeyboardEvent<HTMLInputElement>) {
console.log(`keyDownHandler: ${event.key}`);
if (event.key === "a") {
setTypedText(text => {
console.log("before fetch");
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then(res => console.log("fetch"))
.catch(console.error)
.finally(() => console.log("finally"));
console.log("after fetch");
return text + " ";
});
} else if (event.key === "b") {
setTypedText(text => {
console.log("before alert");
alert("Hello World!");
console.log("after alert");
return text + " ";
});
} else {
setTypedText(text => text + event.key);
}
}
return (
<div className="App">
<div>{typedText}</div>
<input type="text" onKeyDown={keyDownHandler} autoFocus />
</div>
);
}
export default App;
Here is an example of a log generated using the code above to illustrate my point, and to save you the time necessary to install and run all the Node magic.
App.tsx:8 keyDownHandler: a
App.tsx:11 before fetch
App.tsx:18 after fetch
App.tsx:13 fetch
App.tsx:13 finally
App.tsx:8 keyDownHandler: s
App.tsx:8 keyDownHandler: a
App.tsx:11 before fetch
App.tsx:18 after fetch
App.tsx:13 fetch
App.tsx:13 finally
App.tsx:13 fetch
App.tsx:13 finally
App.tsx:8 keyDownHandler: b
App.tsx:24 before alert
<Alert appears>
<Alert appears immediately again>
App.tsx:26 after alert