Ognjen Lazić
the F did I just LearN

the F did I just LearN

Correct way to cancel your axios calls if your components are unomunted

A story retold milion times

So there is a list of some items in your app.
No I am not a fortune teller, it's just that there is one in almost every app.
But let me guess again when you click on an item in that list it opens a detail page of that item.
On that detail page you want to present a image that is related to that item to your user, so we need to fetch that image once our user is on that screen.

For years I have been handling stories like this as presented in the example below and let me guess the third time you have done it in a similar way too.

Do the axios call inside useEffect and change some state when its done, it works every time so it's fine right? Well....

export const ItemDetailsScreen = ({
    route
}) => {
    const [image, setImage] = useState();
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState(false);

    const { item } = route.params;

    useEffect(() => {
        axios.post('/urlWhereImageIs', {
                imageKey: item.imageKey
            })
            .then((result) => {
                setImage(result.data);
            })
            .catch((err) => {
                setError(true);
            })
            .finally(() => {
                setIsLoading(false);
            });
    }, []);

        '//rest of the component render() and stuff'

Overlooked problem

This approach will work but there is one overlooked but a not over smelled problem in this approach.
Axios calls are async, which means that the then, catch and finally blocks will be triggered at unpredictable moment in time.
If loading the image takes time (this can happen for several reasons like slow internet, high resolution images and who knows what) your user might get nervous and go back to the list.

According to a study by Forrester Research, almost half of consumers expect a site to load in two seconds—and if it takes longer than three, 40% leave.

Pretty much this statement is valid for this scenarios too.
Why is this relevant here?
Well there is a huge chance that your axios calls will end after your user goes away from the screen where the result would be presented to them.

This will result in 2 problems:

1 - Your code has a warning and it's a red one

The screen that is rendering image component will get unmounted and axios call will try to change the state of that component so it will result in this error:

error.png

2 - Your network will become a traffic jam

Your axios call will still be running (check the network traffic in the debugger). This is all fun and games until you endup in a situation that you are pulling 20 images.
Your every new axios call will have to wait for the 20 image calls to end.
And the result from that calls won't even be rendered since the user has left the screen.
And that means your user has to wait for something they won't even get, and users don't even like to wait for stuff they want.
We are failing our UX so hard this way.

Let's learn how to solve this

This is where the almighty cancelToken from axios comes in the game.
Here is the code:

export const ItemDetailsScreen = ({
    route
}) => {
    const [image, setImage] = useState();
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState(false);

    const {
        item
    } = route.params;

    useEffect(() => {
        const cancelSource = axios.CancelToken.source();
        let unmounted = false;

        axios.post('/urlWhereImageIs', {
                imageKey: item.imageKey
            }, {
                cancelToken: cancelSource.token
            })
            .then((result) => {
                if (!unmounted) {
                    setImage(result.data);
                }
            })
            .catch((err) => {
                if (!unmounted) {
                    setError(true);
                }
            })
            .finally(() => {
                if (!unmounted) {
                    setIsLoading(false);
                }
            });

        return () => {
            unmounted = true;
            cancelSource.cancel('Canceling axios call');
        };
    }, []);

        '//rest of the component render() and stuff'

And here is the explanation

We just need to create an cancelToken like this:
const cancelSource = axios.CancelToken.source();
Pass that token to our axios call:

axios.post('/urlWhereImageIs', {
    imageKey: item.imageKey
}, {
    cancelToken: cancelSource.token
})


Take a look in axios docs where to put the token in other requests methods.
Once the token is passed to the call we can use:
cancelSource.cancel('Any log message you want');
whenever and wherever to cancel the axios call.
Now we just need to call it when our component is unmounted and that is the return function of our components useEffect:

return () => {
    cancelSource.cancel('Canceling axios call');
};

And here we are no more traffic jam!
If our user goes back to the previous screen our component will be unmounted and axios calls will be canceled!
But we are not done yet!
Canceling axios calls will trigger axios cancel block and of course the finally block and believe it or not there is a small chance that the than block is triggered just when the component is being unmounted if the call is done before we cancel it.
So we are still having the red warning for changing a state that does not exist.
This is where our check is the component alive or not comes in play.
First we create a boolean flag inside our useEffect.

let unmounted = false;


Then we change it to true when the unmount process is started and that is our return function again(same place where we canceled our call).

return () => {
    unmounted = true;
    cancelSource.cancel('Canceling axios call');
};


And the last but not the least thing we check if the component is unmounted or not and decide should we change its state or not:

 .then((result) => {
                if (!unmounted) {
                    setImage(result.data);
                }
            })
            .catch((err) => {
                if (!unmounted) {
                    setError(true);
                }
            })
            .finally(() => {
                if (!unmounted) {
                    setIsLoading(false);
                }
            });

Just a small side note you should keep in mind

If you are working in react native keep in mind that moving forward in navigation will not unmount the screen from which you are going so this only works if you user goes back from the screen where this components are.
I might cover this situation too in a different blog post since there is more things to handle (you need to cancel the calls but you also need to resume the calls if user gets back to the screen) I did not want to cover it on this one since it would be too much to chew.

 
Share this