React 18 came out at the end of March and I upgraded one of our smaller apps at work the week after. The upgrade itself was two package versions and one changed line. Then I opened the network tab and every API call was there twice.
My first thought was that I broke something. My second was that the backend team would have questions. The third one was right: the upgrade guide has a whole section about this, and React does it on purpose.
The one changed line
// src/index.jsx
// React 17
import ReactDOM from "react-dom";
ReactDOM.render(<App />, document.getElementById("root"));
// React 18
import { createRoot } from "react-dom/client";
createRoot(document.getElementById("root")).render(<App />);
If you keep ReactDOM.render, React 18 still works, with a warning in the console:
Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead.
Until you switch to the new API, your app will behave as if it's running React 17.
That is the escape hatch: upgrade the package, keep the old root, change nothing else. But then you have React 18 without anything React 18 does, so switch the root and see what happens.
What happened
In development, with <StrictMode> on, React 18 mounts every component, runs its effects, then immediately runs the cleanups and runs the effects again. As if the component was unmounted and mounted a second time, except state and refs are kept. Production is untouched.
Why would it do that? The React team is working on a feature where React can take a part of the UI off the screen and put it back later with its state intact, think of the back button restoring a page the way you left it. For that to work a component must survive being mounted, unmounted and mounted again with the same state. Most components are fine with it. The ones that aren't have an effect without a matching cleanup, and the double run in development is a test for exactly that.
Function bodies were already called twice in StrictMode since React 17, by the way. React 17 just hid the second console.log, and React 18 stopped hiding it. So if your logs doubled too, that's where they came from.
The effects that failed the test
The fetch
// src/pages/UserPage.jsx, the version that doubled every request
useEffect(() => {
fetchUser(id).then(setUser);
}, [id]);
Two requests in development, one in production, no harm done. Except that this effect was never right. If id changes while the first request is in flight, the slower response can arrive last and you show the wrong user. The fix is a cleanup that tells the old run to drop its result:
useEffect(() => {
let ignore = false;
fetchUser(id).then((user) => {
// the cleanup ran before this resolved, somebody else owns the state now
if (!ignore) setUser(user);
});
return () => {
ignore = true;
};
}, [id]);
An AbortController passed to fetch is the same idea and also cancels the request. Or let a data library own the problem, they all get this right.
The subscription without a cleanup
useEffect(() => {
socket.on("message", handleMessage);
}, []);
After the simulated remount there are two listeners and every message is handled twice. This one leaked in React 17 as well, every time the user navigated away and back. The fix is the one you knew about:
useEffect(() => {
socket.on("message", handleMessage);
return () => socket.off("message", handleMessage);
}, []);
The run once guard
This is the sneaky one, because it looks like a fix.
const didInit = useRef(false);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
connection.connect();
return () => connection.disconnect();
}, []);
Let's walk through it with the double run. First effect: connect, set the flag. Cleanup: disconnect. Second effect: the flag is set, return early. You end up with no connection at all, which is worse than two. Refs survive the simulated remount, so a ref can't tell a remount from a first mount.
The rule I took away: an effect describes how to start something and how to stop it, and it must be fine with being started and stopped any number of times. If something really has to happen once per page load, it doesn't belong in a component. Put it in index.jsx or at module level.
Analytics
Our page view event fired twice in development. I left it alone. It's development, the second event is as fake as the first, and the guard that would prevent it is the one from the previous section. If it bothers you, don't send analytics from development at all.
The other change you'll notice
Updates are now batched everywhere. In React 17, two setState calls inside a promise callback or a setTimeout meant two renders. In React 18 with createRoot, it's one:
async function handleSave() {
await api.save(form);
setSaving(false); // React 17: render
setMessage("Saved"); // React 17: render again. React 18: one render for both
}
Before you turn off StrictMode
I saw the suggestion in a few threads. Please don't do that. Instead:
Going through our effects with that list took the rest of the week, and every one of them was a bug we already had, just quieter.
Read more here:
- React v18.0 by the React team
- How to Upgrade to React 18 by Rick Hanlon
- Adding Reusable State to StrictMode in the React 18 working group
- Automatic batching for fewer renders in React 18 in the React 18 working group