Clean Up Async Requests in `useEffect` Hooks
Tags: react, hooks, webdev
Published: 2019-10-04 23:38:45

In my previous post, we talked about how to replace some component lifecycle functions with `useEffect` and `useReducer` hooks, while making the resource fetching logic re-usable in the app. 

https://dev.to/pallymore/refactoring-an-old-react-app-creating-a-custom-hook-to-make-fetch-related-logic-reusable-2cd9

The custom hook we got at the end looks like this:

```jsx
export const useGet = ({ url }) => {
  const [state, dispatch] = useReducer(reducer, {
    isLoading: true,
    data: null,
    error: null,
  });

  useEffect(() => {
    const fetchData = async () => {
      dispatch(requestStarted());

      try {
        const response = await fetch(url);

        if (!response.ok) {
          throw new Error(
            `${response.status} ${response.statusText}`
          );
        }

        const data = await response.json();

        dispatch(requestSuccessful({ data }));
      } catch (e) {
        dispatch(requestFailed({ error: e.message }));
      }
    };

    fetchData();
  }, [url]);

  return state;
};
```

Looks pretty neat, right? However it has a *critical* flaw - if the `fetch` request is slow, and the component has already unmounted when the async request finishes, you will see this error message from React:

![React Error](https://thepracticaldev.s3.amazonaws.com/i/sj4wl7rstavdvf85hmu8.png)

Or - it could have a serious problem - imagine your component that uses this hook received a different ID before the request finishes - so it tries to fetch data from the new `url`, and the second request finished just a few ms before the first one - what's gonna happen? Your component will be showing the data from the first request!

The great `async/await` might make your code look like it is synchronous, but in reality they are just syntax sugar - your code after `await` will still be executed even your component no longer exists on the page. We should always be careful whenever we want to update the state in an asynchronous function.

How do we prevent this from happening? First of all, we should always try to clean up our effects. 

## The Clean Up Function

If you don't already know - you can return a function at the end of your `useEffect` hook. That function will be called whenever that effect is fired again (e.g. when the values of its dependencies have changed), as well as right before the component unmounts. So if you have a `useEffect` hook that looks like this:

```js
useEffect(() => {
  // logic here

  return () => {
    // clean up
  };
}, []); // no dependencies!
```

It is actually doing the exact same thing as this code:

```
class SomeComponent extends React.Component {
  componentDidMount() {
    // logic here
  }

  componentWillUnmount() {
    // clean up
  }
}
```

If you are attaching an event listener to `window`, `document`, or some other DOM elements, you can use `removeEventListener` in the clean up function to remove them. Similarly, you can clean up `setTimeout`/`setInterval` with `clearTimeout`/`clearInterval`.

## A Simple Solution

Knowing this, you might think: oh well, that's great, we can set a flag that is set to false when the component unmounts so we can skip all the state updates. 

And you are right, that's indeed a very simple solution to this problem:

```js
  useEffect(() => {
    let isCancelled = false;
    const fetchData = async () => {
      dispatch(requestStarted());

      try {
        // fetch logic omitted...
        const data = await response.json();

        if (!isCancelled) {
          dispatch(requestSuccessful({ data }));
        }
      } catch (e) {
        if (!isCancelled) {
          dispatch(requestFailed({ error: e.message }));
        }
      }
    };

    fetchData();

    return () => {
      isCancelled = true;
    };
  }, [url]);
```

In this code - whenever a new effect runs (or the component unmounts), the previous' effect's `isCancelled` is set to `true` - and we only update the state when it is `false`. This makes sure that your `requestSuccessful` and `requestFailed` actions are only dispatched on the latest request.

Mission accomplished!...?

## But You Really Should Do This

There is a better way though. The code above is fine, however, if your `fetch` request is really slow, even if you don't need the results anymore, it is still going on in the background, waiting for a response. Your user might be clicking around and leaving a bunch of stale requests behind - did you know? There is a limit of how many concurrent requests you can have going on at the same time - usually 6 to 8 depending on which browser your users are using. (This applies to HTTP 1.1 only though, things are changing thanks to HTTP/2 and multiplexing, but that's a different topic.) Your stale requests will be blocking newer requests to be executed by the browser, making your app even slower.

Thankfully, there is a new feature in the DOM API called `AbortController` which allows you to cancel `fetch` requests! It is well supported by most browsers (No IE11 though) and we should definitely take advantage of it.

The `AbortController` is very easy to work with. You can create a new one like this:

```js
const myAbortController = new AbortController();
```

and you will find two fields on the instance: `myAbortController.signal` and `myAbortController.abort()`. `signal` is to be provided to the `fetch` call you want to cancel, and when `abort` is called that `fetch` request will be cancelled.

```js
fetch(url, { signal: myAbortController.signal });

// call the line below to cancel the fetch request above.
myAbortController.abort(); 
```

If the request has already completed, `abort()` won't do anything. 

Awesome, now we can apply this to our hook:

```js
  useEffect(() => {
    const abortController = new AbortController();

    const fetchData = async () => {
      dispatch(requestStarted());

      try {
        fetch(url, { signal: abortController.signal });
        
        // code omitted for brevity

        dispatch(requestSuccessful({ data }));
      } catch (e) {
        dispatch(requestFailed({ error: e.message }));
      }
    };

    fetchData();

    return () => {
      abortController.abort();
    };
  }, [url]);
```

Now our `fetch` request will be promptly cancelled for each new effect, or right before the component unmounts.

### Handling Cancelled Requests

Just one little thing though - when a request is cancelled it actually throws an error, so our `catch` block will be executed. We probably don't want to dispatch a `requestFailed` action in this case. Fortunately we can tell if a request has been aborted by checking the `signal` on the `AbortController` instance.

Let's do that in our `catch` block:

```js
try {
 // ...
} catch (e) {
  // only call dispatch when we know the fetch was not aborted
  if (!abortController.signal.aborted) {
    dispatch(requestFailed({ error: e.message }));
  }
}
```

## Wrapping It Up

Now our hook can properly cleans up after itself! If your hook does something async, in most cases they should be cleaned up properly to avoid any unwanted side-effects.

If you are using `fetch`, then `abort` your requests in the clean up function. Some third party libraries also provide a way to cancel requests (like the `CancelToken` from `axios`). 

If you want to support older browsers, or your effect doesn't use `fetch`, but is using some other async operations (like `Promise`), before cancelable `Promise`s becomes a reality, use the `isCancelled` flag method instead.


### Resources

https://developer.mozilla.org/en-US/docs/Web/API/AbortController

https://reactjs.org/docs/hooks-effect.html