DEV Community

loading...

useCancelToken: a custom React hook for cancelling Axios requests

tmns profile image tmns ・4 min read

What's the problem?

When developing with React and updating state inside of components, you may have come across the following error before:

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application.

While this error could pop up for various reasons, one common cause is attempting to update state within the callback of a network request after the component has been destroyed.

For example, imagine we have a modal (yes I know, modals are inherently problematic, but for a lot of us they're also unavoidable) that when opened makes a request for some super important data it needs to set in state and show to the user:

const Modal = () => {
  const [importantData, setImportantData] = useState({});

  useEffect(() => {
    axios.get('/important_data')
      .then((response) => {
        setImportantData(response.data);
      });
  }, []);

  return (
    // JSX with important data
  )
}
Enter fullscreen mode Exit fullscreen mode

Note: While this post is about Axios specifically, the idea can be applied to other APIs, like fetch.

This is nice and works swimmingly when the user opens the modal and keeps it open. But what if they close it while the request is still in process? Sure the component may be gone; however, that callback within .then() is still hanging around waiting to get executed.

Assuming the component unmounts on close, this will cause the error noted above to occupy our console like the US occupying native land, since we'll be attempting to set our importantData state within a component that no longer exists.

What can we do about it?

One solution to this issue is to ensure that whenever our component unmounts, we cancel our pending request.

"But our request is already gone!" I hear you say.. "How can we cancel it??" you yell in despair..

Never fear fellow dev frantically trying to finish a feature before their deadline, as the folks behind Axios have already responsibly built in cancellation functionality!

The idea is that we create a cancel token and send it along with our request, which allows for us to cancel said request whenever we like.

In our Modal component, this would look something like the following:

const Modal = () => {
  const [importantData, setImportantData] = useState({});

  useEffect(() => {
    const source = axios.CancelToken.source(); 
    axios.get('/important_data', {
      cancelToken: source.token
    }).then((response) => {
      setImportantData(response.data);
    }).catch((error) => {
      if (axios.isCancel(error)) return;
    });

    return () => source.cancel();
  }, []);

  return (
    // JSX with important data
  )
}
Enter fullscreen mode Exit fullscreen mode

Notice now we're performing a few extra steps with our lil axios. Before we send the request we now create a source variable holding the result of axios.CancelToken.source, which is like a reference that we can associate with our request.

Then, along with our request we send an extra piece of data, cancelToken, containing our source's token.

However, this alone still doesn't accomplish our goal of cancelling on unmount!

So, we also make sure to return a function from our useEffect that cancels our source's token, which by design will run when the component unmounts.

Also note that when we cancel a token, the pending promise is rejected, resulting in an error. If you don't handle this error, it will pop up in the console.

Conveniently, Axios also provides an isCancel function which allows you to determine if an error returned from a request is due to a cancellation, which you can see above in our catch block.

This is cool for one-off use cases, but realistically we're going to need to reuse this functionality in many components (and even many times in the same component). So let's make our own hook out of it!

Hook, line, something something..

import { useRef, useEffect } from 'react';
import { CancelToken, isCancel } from 'axios';

/**
 * When a component unmounts, we need to cancel any potentially
 * ongoing Axios calls that result in a state update on success / fail.
 * This function sets up the appropriate useEffect to handle the canceling.
 *
 * @returns {newCancelToken: function, isCancel: function}
 * newCancelToken - used to generate the cancel token sent in the Axios request.
 * isCancel - used to check if error returned in response is a cancel token error.
 */
export const useCancelToken = () => {
  const axiosSource = useRef(null);
  const newCancelToken = () => {
    axiosSource.current = CancelToken.source();
    return axiosSource.current.token;
  };

  useEffect(
    () => () => {
      if (axiosSource.current) axiosSource.current.cancel();
    },
    []
  );

  return { newCancelToken, isCancel };
};
Enter fullscreen mode Exit fullscreen mode

The hook, useCancelToken, utilizes useRef to store our cancel token source. This is so that our source remains the same in case of a more complex component where re-renders may occur while a request is being made.

Further, our hook sets up and exports a newCancelToken function, which sets the ref's current value to the created source and returns the token itself, so the consumer can send it along with their request.

I like this approach as I don't think the person using this hook should have to deal with the source object at all. All they should have to do is send the token with the request and let the hook handle the rest!

Last but not least, we set up a useEffect with the sole purpose of cancelling the current source's token on unmount.

Note, we also export isCancel so the consumer can handle their request failure errors appropriately.

So, how would we use this in our Modal component?

import { useCancelToken } from './hooks.js';

const Modal = () => {
  const [importantData, setImportantData] = useState({});
  const { createNewToken, isCancel } = useCancelToken();

  useEffect(() => {
    axios.get('/important_data', {
      cancelToken: createNewToken()
    }).then((response) => {
      setImportantData(response.data);
    }).catch((error) => {
      if (isCancel(error)) return;
    });
  }, []);

  return (
    // JSX with important data
  )
}
Enter fullscreen mode Exit fullscreen mode

Now all we do is call our createNewToken() function when sending our request and check the potentially resulting error with isCancel. We don't even have to set up a cleanup return function!

happy dance

Conclusion

When developing it can be easy to forget about all the uncertainties and gotchas of real-world use.

Maybe the end user's network isn't as fast as the one we're developing on. Maybe they lose connectivity midway through using a feature. Maybe they didn't want to use said feature at all and navigate away from it / close it immediately. And so on and so on.

Thus, it's important to program a bit defensively and ensure we cover our bases. Using a cancel token for async routines is one such example.

Also - I wrote tooken instead of token way too many times while writing this. Also also - tooken > taken.

Discussion (0)

pic
Editor guide