Why your useEffect is firing twice in React 18
React 18 has been out for about 8 months now and it contains a ton of goodies for both end-users and library authors. However, there is one new change that seems to keep coming up in GitHub issues and forum posts – useEffect
now fires twice under Strict Mode in development. In this post, I’ll briefly go over exactly what Strict Mode in React is and then offer some advice on dealing with the change – as well as a few useEffect
best practices.
What is Strict Mode?
In all likelihood, you’re already using Strict Mode in React. It’s on by default when you bootstrap a project with something like create-react-app
or create-next-app
. If you take a look at your index.js
file in a create-react-app
project, you’ll see something like this:
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
// this is setting your entire app to Strict Mode
<React.StrictMode>
<App />
</React.StrictMode>
);
When Strict Mode is enabled, React will raise warnings in development whenever it comes across certain code or conventions that are problematic or outdated. If you were around when Hooks were introduced, you might recognize this Strict Mode warning about old class-based lifecycle methods:
Warning: componentWillMount has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details.
* Move code with side effects to componentDidMount, and set initial state in the constructor.
* Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 17.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.
In addition to flagging the UNSAFE_
lifecycle methods (the other two are componentWillReceiveProps
and componentWillUpdate
), Strict Mode will warn you about using:
- the legacy string
ref
API - the deprecated
findDOMNode
method - the legacy
context
API
Strict Mode also includes a feature to help you detect unexpected side effects. To help surface these issues, React intentionally double-invokes these functions in development:
constructor
,render
, andshouldComponentUpdate
getDerivedStateFromProps
- Function component bodies
- Functions passed to
useState
,useMemo
, anduseReducer
This behavior was present in React 17, but you might not have noticed it because React would dedupe console.log
calls. You will probably notice it now, however, because that behavior is gone in React 18.
Note: The TL;DR for double-invoking is that React does stuff in two phases – render, where it determines what has to change and commit, where it applies those changes. Since the render phase can be slow, React will eventually break this phase into pieces. This means that parts of this phase may be repeated to ensure the state is correct and up to date. Since any of the above functions could actually be called twice in production, the double-invoking will highlight code that is not idempotent (which means that for a given input, your function will always return the same output – or “your function always does the same thing, no matter how many times it’s called”).
Now that we know what Strict Mode is (and the lede has been sufficiently buried), let’s figure out what’s got everyone so riled up.
The new Strict Mode feature
To get to the point – in development mode in React 18, your components will unmount and remount whenever they are mounted for the first time. In practice, this means the following functions will also be called twice:
componentDidMount
componentWillUnmount
useEffect
useLayoutEffect
useInsertionEffect
(this one is for library authors, but I’m including it for completeness)
The React docs have a decent explanation of why they added this behavior, but it boils down to ensuring any functions that have side effects also have adequate cleanup. This comment from React maintainer, Dan Abramov, sums it up pretty nicely:
The mental model for the behavior is “what should happen if the user goes to page A, quickly goes to page B, and then switches to page A again”. That’s what Strict Mode dev-only remounting verifies.
If your code has a race condition when called like this, it will also have a race condition when the user actually switches between A, B, and A quickly. If you don’t abort requests or ignore their results, you’ll also get duplicate/conflicting results in the A -> B -> A scenario.
Once again – this is only in development. This behavior is not present in your production build. If you don’t want this feature, you can disable Strict Mode by removing the <React.StrictMode>
tags from your index.js
file. However, this is not recommended by the React team – the best course of action is to handle these cases where user behavior could cause your components to rapidly unmount and remount. So how can we do that?
Clean up your useEffect
Basically, we just need to make sure we return a cleanup function from useEffect
when applicable. For example, if you have an event listener that is set up in a useEffect
, make sure you remove it on unmount:
useEffect(() => {
window.addEventListener("resize", handleEvent);
return () => {
window.removeEventListener("resize", handleEvent);
};
}, []);
Another common case is doing asynchronous work inside useEffect
. Let’s say we make some async call and then set state with the eventual return value. If the component unmounts before the promise resolves, that could represent a possible memory leak. In order to avoid this, we can use a boolean flag to set the state only when the component is actually mounted:
useEffect(() => {
// set flag to `true` on mount
let mounted = true;
const getData = async () => {
const response = await somePromise();
// check flag before setting state
if (mounted) {
setData(response);
}
};
getData();
return () => {
// set to false on unmount
mounted = false;
};
}, []);
If your async function is fetching something, you can also use an AbortController
:
useEffect(() => {
// create AbortController
const abortController = new AbortController();
const getData = async () => {
try {
const response = await fetch(someUrl, {
signal: abortController.signal, // add signal to fetch call
});
const json = await response.json();
// similar to `mounted` check, confirm
// we haven't unmounted before setting state
if (!abortController.signal.aborted) {
setData(json);
}
} catch (error) {
console.log(error);
}
};
getData();
return () => {
// cancel the fetch
abortController.abort();
};
}, []);
In both cases, you may still see two requests happening in your network logs in production – that’s expected. In the first case, we don’t actually cancel the call – we just don’t set state if the component unmounts. In the second case, that’s just how AbortController
works.
It should also be noted that these two examples are somewhat naive in that there are typically more things to consider (for example, the AbortController.abort()
method throws an error that you should probably handle). If managing this kind of logic becomes too complex, you can always reach for some battle-tested libraries that have already figured this out for us. These are a few of the more popular ones:
react-query
redux-toolkit
(specifically, RTK Query)react-async
Recap
While React 18 brings a bunch of new features, the new Strict Mode behavior seems to have stolen the spotlight a little bit. However, there’s no need to fear because it’s working as intended and helping us catch bugs before we push to production. As long as we handle the clean up of our useEffect
calls, we should be good to go! And if you’d like to read more about some of the other new things in React, you can check out their official announcement post .