circuit

React 18 useEffect Double Call for APIs: Emergency Fix

React 18 API Calls need an 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.




Continue Learning