Cleaning up after AJAX function in custom React hook

Viewed 642

I've written a custom React hook that uses jQuery to fetch results from a given URL via AJAX (this is in the context of a legacy application, hence the need to use jQuery for AJAX), the API for which is loosely inspired by axios-hooks. The code for this is as follows (note it includes Flow annotations):

//@flow
import { useCallback, useState, useEffect } from "react";
import type { jquery } from "jquery";

declare var $: jquery;

function useAjaxGet(url: string) {
  const [loading, setLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const fetchData = useCallback(() => {
    setLoading(true);
    $.ajax({
      type: "GET",
      url: url,
      dataType: "json"
    }).done((data) => {
      setData(data);
      setLoading(false);
      setError(false);
    }).fail(() => {
      setLoading(false);
      setError(true);
    });
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [url, fetchData]);

  return [{
    data: data,
    loading: loading,
    error: error
  }, fetchData];
}

export default useAjaxGet;

Now, this works for simple applications, but sooner or later it throws the following error:

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. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

I understand the fact that I have to return a cleanup function, and that the failure to do so is causing a memory leak. However, I'm struggling to understand what I can do to clear it up in this case. Can anyone throw any light on this?

1 Answers

There are many ways to cleanup the component. The main concept is simple: you shouldnt update the state in an umount react element.

Solution 1: Avoiding to update the state manually using an isMount variable

  const fetchData = useCallback(() => {
    let isMounted = true
    setLoading(true);
    $.ajax({
      type: "GET",
      url: url,
      dataType: "json"
    }).done((data) => {
      // Before updating the state, check if the element has been unmounted while waiting
      if (!isMounted) {
        return;
      }
      setData(data);
      setLoading(false);
      setError(false);
    }).fail(() => {
      // Same, check if the element has been unmounted while waiting
      if (!isMounted) {
        return;
      }
      setLoading(false);
      setError(true);
    });
    // Cleanup function
    return () => {
      isMounted = false;
    };
  }, [url]);

You can see this solution on Facebook's Relay Docs.

Demo using isMounted

Solution 2: Cancelling the request on unmount lifecycle (AJAX jQuery)

This will avoid the execution of all callbacks of the promise. Much cleaner! (Following example uses jQuery v3)

  useEffect(() => {
    const xhr = new window.XMLHttpRequest();
    $.ajax({
      url: url,
      xhr: function() {
        return xhr;
      }
    })
    .then(response => {
      setState(response);
    })
    .catch(error => {
      console.log(error);
    });
    return () => {
      xhr.abort(); // Cancel the request on unmount
    };
  }, [url]);

Demo using Ajax request cancel

Solution 3: Cancelling the request on unmount lifecycle (fetch)

Same as Solution 2, but using fetch, since the usage is higher.

  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;
    fetch(url, { signal })
      .then(res => res.json())
      .then(response => {
        setState(response);
      })
      .catch(error => {
        console.log(error);
      });
    return () => {
      controller.abort();
    };
  }, [url]);

Demo using AbortController

Related