React hooks and REST - canceling in hooks
6th February, 2020
4 min read
Did you know you can cancel Ajax requests?
You can, and if you are using React, you should!
Avoiding updating unmounted state
If you've worked with React and asynchronous processes you'll have seen the following error message in your dev console.
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. To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount method.
Like the error says - if you've subscribed to an event listener, pubsub, or timer you'll want to remove or cancel it when the component unmounts. Don't let the reference to componentWillUnmount
phase you - we're using hooks, so we'll handle the unmount by returning a cleanup function.
// for illustrative purposes only - very contrived code!
useEffect(() => {
const myTimer = setTimeout(() => {
doSomethingStateful();
}, 1000);
return () => {
myTimer.clear();
};
}, [])
AJAX
The other asynchronous process we have to deal with is of course AJAX requests. Below is an example component that makes an AJAX request, using a hook with an empty dependency list to ensure it runs once when the component mounts
function YourComponent() {
const [ isLoading, setIsLoading ] = useState(false);
const [ theList, setTheList ] = useState([]);
useEffect(() => {
setIsLoading(true);
client
.get('/your/endpoint', {
...config,
})
.then(response => {
setTheList(response.data);
})
.finally(() => {
setIsLoading(false);
});
},
// empty dependency list means this _should_ run once when the component mounts
[]);
return (<>
{isLoading && <LoadingComponent />}
{!isLoading && <DataList list={theList} />}
</>);
}
How does this trigger the 'unmounted state update' error?
useEffect is a closure
When you execute useEffect
you are creating a closure, the scope of which encloses any asynchronous code and handlers, and other effects - useState
in the above example. If you navigate away from the view that renders the component in question it will unmount the component, but if the AJAX call is in-flight, the useEffect
closure will not be garbage collected. When the AJAX response is received and the promise resolves, the then
handler in the useEffect
will run the setTheList
method - which updates the component state - and you'll get your 'unmounted state update' error.
Axios cancel tokens - winners don't do isMounted
The old school way of handling this would have involved tracking the mounted state of the component in the useEffect
closure - checking isMounted
is true before running any state-changing code. This does work, but instead of cancelling an asynchronous process directly we are monitoring a proxy - the mounted state. I'm all for abstraction, and in a more complex component it might make sense, but our simple component requires one thing : a cancelled AJAX request when the component is unmounted.
The popular and ubiquitous axios client provides a CancelToken
to facilitate cancelling in-flight requests.
const token = axios.CancelToken.source();
Once we have the token, we provide it as part of the config when we make our request
axiosInstance.get(someUrl, {
...config,
cancelToken: token
});
The CancelToken.source()
also provides a cancel
method
const { cancel } = token;
We can cancel any in-flight request using this cancel
method.
canceler.cancel();
Cancelling a request only affects the AJAX client. We're not sending an update to the server or anything like that. I won't be detailing how cancelling works - an explanation of the details wouldn't be very useful, and would distract from the point of this article.
When we execute cancelToken.cancel()
the request Promise is rejected with a special error. We'll need to distinguish this cancel error using another axios utility method axios.isCancel
as we want to handle that 'error' differently in the Promise catch block. We can push the details of how we setup the axios instance and cancel token into a module and now putting this all together, the updated React component is below
function YourComponent() {
const [ isLoading, setIsLoading ] = useState(false);
const [ theList, setTheList ] = useState([]);
const { axiosClient, token } = createClient();
useEffect(() => {
setIsLoading(true);
axiosClient
.get('/your/endpoint', {
...config,
cancelToken: token,
})
.then(response => {
setTheList(response.data);
})
.catch(error => {
// we only want to run the state changing `setIsLoading`
// if the error is not the special `Cancelled` error thrown by axios
!isCancel(error) && setIsLoading(false);
});
// we return a 'cleanup' method that will be run when the component unmounts
return () => {
token.cancel();
};
},[]);
return (<>
{isLoading && <LoadingComponent />}
{!isLoading && <DataList list={theList} />}
</>);
}
No finally
You'll notice I have not used the finally
method - cancelled Axios promises do not run the finally
block.