x

Webpusher

Mark Lennox

a regrettable tendency to Javascript

Mark Lennox Javascript, C#, Python, machine learning, web, devops, mobile

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.