DEV Community

tmns
tmns

Posted on • Updated on

useCancelToken: a custom React hook for cancelling Axios requests

Update

Note that as of Axios v0.22.0, the cancelToken API is deprecated in favor of using a fetch-like AbortController.

There are already various hooks available online for AbortController so I won't recreate it here but at a high-level it's conceptually quite similar to the cancel token!

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, useCallback } 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 = useCallback(() => {
    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. Note, that this function is memoized via useCallback, so that it can safely be added to a useEffect dependency array without causing an infinite loop of rendering.

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 { newCancelToken, isCancel } = useCancelToken();

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

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

Now all we do is call our newCancelToken() 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

*Note that calling newCancelToken multiple times within the same component won't actually allow you to cancel multiple requests. For that you either need to call it once and pass the cancelToken to each request (also storing it in a ref if the component might re-render) or tweak the hook to return the cancelToken instead of the function, as Mostafa helpfully explains in their comment.

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 (9)

Collapse
mostafaomar98 profile image
MostafaOmar98 • Edited on

Hi, thanks for your article.

I believe that the currentuseCancelToken is faulty in some cases.

Currently: it exports a newCancelToken method that generates a new cancel token on each call of this function, and saves the latest cancel token source object on a ref. This will cause a problem in a component that does 2 different api calls with 2 different tokens. This hook will only cancel the latest of them on unmount.

i.e., a component that looks like this
function A() {
    const [x, setX] = useState<number>(0);
    const {newCancelToken, isCancel} = useCancelToken();
    const url = "https://httpbin.org/delay/3";

    useEffect(() => {
        (async () => {
            try{
                await axios.get(url, {
                    cancelToken: newCancelToken()
                });
                console.log("Done 1");
                setX(1);
            } catch(error) {
                if (isCancel(error)) console.log("canceled 1");
                else console.log(error);
            }
        })();

    }, []);


    useEffect(() => {
        (async () => {
            try{
                await axios.get(url, {
                    cancelToken: newCancelToken()
                });
                console.log("Done 2");
                setX(2);
            } catch(error) {
                if (isCancel(error)) console.log("canceled 2");
                else console.log(error);
            }
        })();

    }, []);


    useEffect(() => {
        (async () => {
            try{
                await axios.get(url, {
                    cancelToken: newCancelToken()
                });
                console.log("Done 3");
                setX(3);
            } catch(error) {
                if (isCancel(error)) console.log("canceled 3");
                else console.log(error);
            }
        })();

    }, []);

    return <div>Hey {x}</div>
}
Enter fullscreen mode Exit fullscreen mode

I've reproduced this bug in the code in this repo here: github.com/MostafaOmar98/use-cance...

When clicking unmount A quickly (before allowing the api calls to finish),
Expected result: logs to console "canceled 1", "canceled 2", "canceled 3" (order doesn't matter) with no warnings
Current result: it logs canceled 3, Done 1, Done 2, and spits out the setState warning

The suggested implementation is to create only one cancel token per component and cancel all api calls on unmounting:

export const useCancelToken = () => {
  const axiosSource = useRef<CancelTokenSource>(axios.CancelToken.source());
  const isCancel = axios.isCancel;

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

  return { cancelToken: axiosSource.current.token, isCancel };
};
Enter fullscreen mode Exit fullscreen mode

Also, as an addition to the article. This hook only deals with cancelling api calls on unmounting. Another use case for cancelling is cancelling api calls on dependency changes. For example:

...
const userId = props.userId;
useEffect(() => {
  // load something based on userId
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

If the userId changes quickly, we might want to cancel the old ongoing request. I believe it is better to create the CancelToken/AbortController locally inside the effect and cancel/abort in the return function of the effect without the usage of extra hooks. Do you have another suggestion or pattern for doing this?

Thanks!

Collapse
tmns profile image
tmns Author

Hey Mostafa!

Thanks for the thorough reply and bringing up the issue about creating multiple cancel tokens within the same component. It's true that if you're going to be using it in several different calls like that you'd either want to just create one cancel token and export it directly (like you suggested) or create the cancel token once in the calling component (and storing it in a ref if it may rerender). I think your approach is the simplest as long as a function isn't specifically needed for some reason. I'll add a little note to the article pointing to your comment 🙌

As for your other point about the use cases of the hook. Yes, this is only about unmounting. There are maaaany cases where you may want to cancel a request, which may not even require a dependency change (e.g. network issues and race conditions). For those cases I've typically just handled them manually like you suggested, as they tend to be more difficult to easily abstract than the classic "if component unmounts." If you come up with a better way though let me know!

Have a great one!

Collapse
mostafaomar98 profile image
MostafaOmar98

Thanks for the reply and the feature on the article! Have a great one too!

Collapse
sgdxa profile image
𝗗 𝗶 𝗸 𝗼

Hi, thank you for the tutorial. One question from me.

If I have this code

api({
method: 'POST',
url: URL,
params: {
'user-token': Token,
},
});

*api is from axios instance

where should i put the cancelToken?

Collapse
tmns profile image
tmns Author

Hey there Diko!

In your code the cancel token would go within that same config object. So something like:

api({
  method: 'POST',
  url: URL,
  params: {
    'user-token': Token,
  },
  cancelToken: source.token
});
Enter fullscreen mode Exit fullscreen mode

Note however that if you're using at least Axios v0.22.0 the cancel token API has been deprecated in favor of a fetch-like abort controller.

Collapse
negreanucalinadrian profile image
Calin Negreanu

Thanks for the hook! This saved me

Collapse
josh231101 profile image
Josue Arreola

This is just a beauty, thank you so much!

Collapse
zeeshan4242 profile image
Zeeshan

Thanks a lot! I have been searching for this for so long. You explained very easily how to achieve it using a hook. Thanks again.

Collapse
tmns profile image
tmns Author

My pleasure! Glad it helped :)