DEV Community

Klemen Slavič
Klemen Slavič

Posted on

Stop wasting bandwidth with React's `useEffect` and `fetch`!

You've been in this situation before. You have a component that needs to fetch some data, which sounds like a no-brainer, right? Just use useEffect() to load the data, set it into state and you're done. You slide the keyboard in front of you and start typing out your component:

// ...

interface Post {
  image: string;
  title: string;
  link: string;
}

function App() {
  const [releases, setReleases] = useState<null | Post[]>(null);

  useEffect(() => {
    fetch(targetUrl)
      .then((response) => response.json())
      .then((data) => {
        const converted = extractThumbnails(data);
        console.log("Got data!", converted);
        setReleases(converted);
      })
      .catch((err) => {
        console.error(err);
        setReleases([]);
      });
  }, []);

  // ...
}
Enter fullscreen mode Exit fullscreen mode

At first glance it seems to work just fine when you load it up in the browser:

However, if you're running this example using React 18 in development and strict mode (like the above example), every useEffect hook runs twice when the component is mounted.

huh?

Apparently, it is an all too common problem in React codebases to not correctly handle clean-up when using side effects. There's even an entire page dedicated to why you shouldn't use it for. So the React developers decided to force the useEffect hook to fire twice in development mode to surface problems like these early and break things in an obvious way if not coded correctly.

I may not agree that the behaviour of hooks should change when switching between production and development modes, I do wholeheartedly agree that effect hooks should correctly return clean-up functions when necessary. So let's fix our example.

First off, let's think about why the example above might be problematic in the first place. If the effect runs twice, then two requests will fire and the last one to arrive will be the one to determine what kind of data will be shoved into the state. A race condition.

A diagram showing two parallel requests being made. The second request finishes before the first and thus the first request overwrites the state of the component. It also causes the state to be updated twice.

There are two ways to solve this problem:

  1. ignore the response from the first request; or
  2. cancel the previous request.

If you look at the documentation example on React's documentation page, the authors outline the first solution:

useEffect(() => {
  let ignore = false;
  fetch(targetUrl)
    .then((response) => response.json())
    .then((data) => {
      if (!ignore) {
        const converted = extractThumbnails(data);
        setReleases(converted);
      }
    })
    .catch((err) => {
      console.error(err);
      setReleases([]);
    });
  return () => {
    ignore = true;
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

While this does avoid the race condition, it still fires two requests, the first of which will always be ignored. This will waste bandwidth, of course, but it will prevent the state from being updated twice.

The other problem I have with this solution is that the if (!ignore) check has to be on the last step of the promise chain, meaning the response will always be decoded. You could conditionally throw an error on every promise chain step, but forgetting to do that in an intermediate step will just waste more time on a throwaway result.

Let's look into how to implement the second solution, then. To cancel a request using fetch() , we must create an instance of AbortController and pass its signal property into the configuration. We can then use the controller to call its abort() method when the request needs to be cancelled:

useEffect(() => {
  const controller = new AbortController();
  fetch(targetUrl, { signal: controller: signal })
    .then(response => response.json())
    .then(data => {
      const converted = extractThumbnails(data);
      setReleases(converted);
    })
    .catch(err => {
      if (err.name != 'AbortError') {
        console.error(err);
      }
    });
  return () => controller.abort();
}, []);
Enter fullscreen mode Exit fullscreen mode

When a request is cancelled, the promise rejects with a DOMException whose name property equals "AbortError". Since we can treat request cancellation as an expected error, we only report other errors to the console.

This now results in the following timeline:

The first request is now cancelled and the promise rejects with an error, which ensures that only the second request's response sets the component's state and is free from race conditions.

The nice thing about this approach is that the promise chain will simply reject at whichever point abort() gets called and doesn't require us to check a variable state at each point in the chain just to avoid wasted work. It also helps with readability — the returned function calls abort() and the promise chain handles that case in the catch() case, as it should.

Now that this problem is solved for the double useEffect execution in dev mode, it also solves the problem of quickly switching between components on a slow network — components won't continue to request and download responses after they're unmounted. On a slow mobile connection, this could mean the difference between an OK experience and a REALLY bad one.

And here's the implementation with AbortController:

Top comments (0)