Stop wasting bandwidth with React's `useEffect` and `fetch`!
Tags: react, beginners, javascript, productivity
Published: 2023-06-21 12:57:30

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()`](https://react.dev/reference/react/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:

```tsx
// ...

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([]);
      });
  }, []);

  // ...
}
```

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

{% embed https://stackblitz.com/edit/vitejs-vite-x6c8v3?embed=1&file=package.json&view=preview %}

_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?](https://media.giphy.com/media/KFt2DA9T82paOA1Yci/giphy.gif)

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](https://react.dev/learn/you-might-not-need-an-effect). 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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6az62oh391v4ma245qp4.png)

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](https://react.dev/learn/synchronizing-with-effects#fetching-data):

```tsx
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;
  };
}, []);
```

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:

```tsx
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();
}, []);
```

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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/whrmpzosyi97zye1lkxw.png)

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`:

{% embed https://stackblitz.com/edit/vitejs-vite-pjsyjy?embed=1&file=src%2FApp.tsx %}