Making useEffects abortable

August 12, 2021

The problem

State updates that are run inside useEffect are problematic when the component running said useEffect is no longer mounted on the DOM since they cause memory leaks and race conditions. The way this is often guarded against is by putting a check in place to only update the state when the component is mounted, like so:

useEffect(() => {
  // we're certain component has mounted
  let mounted = true;

  const fetchSomeData = async () => {
    setFetchIsPending(true)
    const freshData = await fetchStuffUsingApi()

    // conditionally update state
    if (mounted) {
      setData(freshData)
      setFetchIsPending(false)
    }
  }

  fetchSomeData()

  // cleanup - component is unmounting
  return () => mounted = false
}, [*dependencies*]);

This way we're sure to update the data and loading states only when the component is mounted, preventing the following, quite common, 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.

The solution

While doing something along the lines of the example above is fine, it's not very DRY when the same logic is repeated across many hooks. Also, you might just forget to add the checks in and only realise once a bug surfaces.

One approach to providing an abstraction for this is to create a wrapper around the useEffect hook that only runs the given the hook if the componet has not aborted, i.e. unmounted. We could call this useAbortableEffect. Here's an example:

useAbortableEffect((effect,dependencies) => {
  // again, we define some state for the mount status
  const status = { aborted: false };

  React.useEffect(() => {
    // pass the status to the hook consumer
    // & store the clean up function if the effect returns one
    const cleanUpFn = effect(status);

    return () => {
      // unmounting now, signal this to the hook consumer
      status.aborted = true;

      // if a clean up function was returned, run it
      if (typeof cleanUpFn === 'function') {
        cleanUpFn();
      }
    };
  }, [...dependencies]); // eslint-disable-line react-hooks/exhaustive-deps
}

And then in the consumer, the orignal example would turn into this:

useAbortableEffect(
    // pass the effect function
    status => {
      const effect = async () => {
        setFetchIsPending(true);
        const freshData = await fetchStuffUsingApi()
          if (!status.aborted) {
            setData(freshData);
            setFetchIsPending(false);
          }
        }
      effect();
    },
    // pass the dependencies
    [*dependencies*]
  );

Now we have an abstraction for handling aborted effects across our hooks. The solution is not perfect, but it does the job until something better is worked out.