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