React 18 useEffect Double Call for APIs: Emergency Fix

So you’ve upgraded to React 18, enabled strict mode, and now all of your useEffects are getting called twice.
Which would normally be fine, but you have API calls in your useEffects so you’re seeing double traffic in development mode. Sound familiar? No problem, I’ve got your back with a bunch of potential fixes.
Fix 1: Live With It
A legitimate option is simply to live with it, it’s dev-mode behavior only. It’s also trying to help you by stress-testing your components to ensure they are compatible with future features in React. But, hey, I get it, you are here, you don’t like it, so … let’s just move on.
Fix 2: Remove Strict Mode
It is strict mode that is causing the double render, so another option is just to remove it. Out of the box the StrictMode component is used in index.js
and it’s here:
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
So simply remove it, like so:
root.render(<App />);
That being said, I don’t recommend this route since strict mode does a lot of good checking on your app code so you really consider keeping it around.
Fix 3: Use An Abort Controller
Another fix is to use an AbortController to terminate the request from the first useEffect
. Let’s say this is your code:
const [people, setPeople] = useState([]);
useEffect(() => {
fetch("/people")
.then((res) => res.json())
.then(setPeople);
}, []);
This code was fine (sort-of) in React 17, but strict mode in 18 is showing an issue by mounting, unmounting, and re-mounting your component in development mode. And this is showing off that you aren’t aborting the fetch if it hasn’t been completed before component un-mount. So let’s add that AbortController
logic.
useEffect(() => {
const controller = new AbortController();
fetch("/people", {
signal: controller.signal,
})
.then((res) => res.json())
.then(setPeople);
return () => controller.abort();
}, []);
The code is pretty simple. We create a new AbortController
then we pass its signal
to the fetch
and in our cleanup function we call the abort
method.
Now what’s going to happen is that the first request will be aborted but the second mount will not abort and the fetch will finish successfully.
I think most folks would use this approach if it weren’t for one thing, that in the Inspector you see two requests where the first one is in red because it has been cancelled, which is just ugly.
Fix 4: Create a Custom Fetcher
A cool aspect of a JavaScript promise is that you can use it like a cache. Once a promise has been resolved (or rejected) you can keep calling then
or catch
on it and you’ll get back the resolved (or rejected) value. It will not make a subsequent request on a fulfilled promise, it will just return the fulfilled result.
Because of that behavior, you can build a function that creates custom cached fetch
functions, like so:
const createFetch = () => {
// Create a cache of fetches by URL
const fetchMap = {}; return (url, options) => {
// Check to see if its not in the cache otherwise fetch it
if (!fetchMap[url]) {
fetchMap[url] = fetch(url, options).then((res) => res.json());
} // Return the cached promise
return fetchMap[url];
};
};
This createFetch
function will create a cached fetch
for you. If you call it with the same URL twice, it will return the same promise both times. So you can make a new fetch
like so:
const myFetch = createFetch();
And then use it in your useEffect
instead of fetch
with a simple replace:
const [people, setPeople] = useState([]);
useEffect(() => {
myFetch("/people").then(setPeople);
}, []);
Here is why this works. The first time the useEffect
is called the myFetch
starts the fetch
and stores the promise in the fetchMap
. Then the second time the useEffect
function is called it the myFetch
function returns the cached promise instead of calling fetch
again.
The only thing you need to figure out here is cache invalidation if you choose to use this approach.
Fix 5: Use React-Query
None of this is a problem if you use React-Query. React-Query is an amazing library that you should honestly be using anyway. To start with React-Query first install the react-query
NPM package.
From there create a query client and wrap your application in a QueryProvider
component:
import { QueryClient, QueryClientProvider} from "react-query";...const AppWithProvider = () => (
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>
);
Then in your component use the useQuery
hook, like so:
const { data: people } = useQuery("people", () =>
fetch("/people").then((res) => res.json())
);
Doesn’t that just look better anyway? And it doesn’t do the double fetch.
This is just the tiniest fraction of what React-Query can do. And folks are using React-Query for more than just fetch, you can use it to monitor any promise-based asynchronous work you do.
Fix 6: Use a State Manager
I’m not going to go into code detail on this one since it depends a lot on the state manager you use. But if you use Redux then use then if you use the RTK Query functionality in Redux Toolkit you won’t be affected by this double-mount behavior.
What You Shouldn’t Do
I strongly recommend against using useRef
to try and defeat this behavior. There is no guarantee that the component that gets called on the first useEffect
is the same one that gets called the second time around. So if you do things like use useRef
to do tracking between mounts then… it’s unclear if that is going to work.
Also, the code that is currently going around to create a useEffectOnce
doesn’t work. It does not call the cleanup function. Which is far worse behavior than having useEffect
called twice.
What You Should Do
If you like this content then you should check out my YouTube channel. I cover topics like this all the time. In fact, I’ve already covered the useEffect
topic already over there, but I haven’t covered the API call aspect specifically…yet.
More content at PlainEnglish.io. Sign up for our free weekly newsletter. Follow us on Twitter and LinkedIn. Check out our Community Discord and join our Talent Collective.