Aborting side-effects in React components

February 15, 2019

Picking up from my previous post about Aborting side-effects, which explained how AbortController can be used to cancel any side-effect, let's explore how we can ensure that side-effects started during the lifecycle of a React component are canceled when the component unmounts.

Starting with React 16.8 we can use the useEffect hook where we'd previously use the componentDidMount, componentDidUpdate and componentWillUnmount lifecycle methods. useEffect takes a function that starts the effect and that returns yet another function cleanup after the effect.

ts
useEffect(() => {
startEffect();
return stopEffect;
});

The official docs explain this in detail, but the above will call startEffect after every render, cleaning up with stopEffect starting with the second render. We can pass a second argument to make it start and cleanup only when some props have changed ([props, that, matter]), or [] to start the effect only after the first render, and cleanup only on unmount.

But we'll also need to keep a renference to the AbortController somewhere. With a class component we would create an instance. With a function component using a normal variable in our function component won't work because it'd get reset on every render. The useRef hook can be used for this:

ts
const abortControllerRef = useRef(null);

At first glance these two hooks seem enough to get the job done, but as soon as you want some state changed when the effect gets canceled, problems arise. In the previous post we discussed how an AbortError is thrown when canceling suceeds. We'd normaly then update the state to represent this, but if we do so, this will also happen when we cancel the effect because the component is unmounting --and changing state at that point is logically not allowed.

To resolve this, we can make the reference null to signify that we are in the cleanup process, and bail out of any state changes if that's the case.

Let's look at a full example of a component to download a large video.

tsx
import React, { FunctionComponent, useRef, useState } from 'react';
type DownloadStatus =
| { status: "Initial" | "InProgress" | "Complete" }
| { status: "Error"; errorMessage: string };
const assertNever = (unexpected: never): never => {
throw new Error(`Unexpected value: ${unexpected}`);
};
export const LargeVideoDownloader: FunctionComponent<{}> = () => {
const abortControllerRef = useRef<AbortController>(null);
const [downloadStatus, setDownloadStatus] = useState<DownloadStatus>({
status: "Initial"
});
// The effect will run once the component is mounted.
useEffect(() => {
// This will run when the component is about to unmount.
return () => {
if (downloadStatus.status !== 'InProgress') return;
const {
current: abortController
} = abortControllerRef;
if (!abortController) return;
abortControllerRef.current = null;
abortController.abort();
};
}, []);
const getControllerOrThrow = (): AbortController => {
if (!abortControllerRef.current) {
throw Error('The AbortController should have been instantiated at this point. :s');
}
return abortControllerRef.current;
};
const startDownloading = async () => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const result = await fetch('someURL', {
signal: abortController.signal
});
/* Here do something with `result`, maybe. */
} catch (error) {
// We only want to attempt to change state if there is an active
// controller ref. Otherwise, it means we are unmounting and we
// shouldn't change state.
if (abortControllerRef.current) {
setIsDownloading(false);
setErrorMessage(error.message);
}
return;
}
setIsDownloading(true);
setErrorMessage(undefined);
};
const handleDownloadCancel = () => getControllerOrThrow().abort();
switch (downloadStatus.status) {
case "Initial":
return (
<p>
Ready? <button onClick={startDownloading}>Go!</button>
</p>
);
case "InProgress":
return (
<p>
Downloading… <button onClick={handleDownloadCancel}>Cancel</button>
</p>
);
case "Complete":
return (
<p>
Download complete! :){" "}
<button onClick={startDownloading}>Download again because yes</button>
</p>
);
case "Error":
return (
<p>
Error while downloading: {downloadStatus.errorMessage}.{" "}
<button onClick={startDownloading}>Retry</button>
</p>
);
default:
return assertNever(downloadStatus);
}
};

Introducing useAbortableSideEffect

I think we'll all agree that this type of code is complex and error prone. Realizing this I ventured to abstract the complexity away in a single hook. I called it useAbortableSideEffect, and published it on npm and GitHub.

With useAbortableSideEffect you can simplify the above example code significantly:

tsx
import React, { FunctionComponent, useState } from "react";
import useAbortableEffect from "react-use-abortable-effect";
type DownloadStatus =
| { status: "Initial" | "InProgress" | "Complete" }
| { status: "Error"; errorMessage: string };
const assertNever = (unexpected: never): never => {
throw new Error(`Unexpected value: ${unexpected}`);
};
export const LargeVideoDownloader: FunctionComponent<{}> = () => {
const downloadEffect = useAbortableEffect(signal =>
fetch("some_url", { signal }),
[]
);
const [downloadStatus, setDownloadStatus] = useState<DownloadStatus>({
status: "Initial"
});
const startDownloading = async () => {
setDownloadStatus({ status: "InProgress" });
try {
await downloadEffect.start();
} catch (e) {
const errorMessage = e instanceof Error ? e.message : e.toString();
setDownloadStatus({ status: "Error", errorMessage });
return;
}
setDownloadStatus({ status: "Complete" });
};
switch (downloadStatus.status) {
case "Initial":
return (
<p>
Ready? <button onClick={startDownloading}>Go!</button>
</p>
);
case "InProgress":
return (
<p>
Downloading… <button onClick={downloadEffect.abort}>Cancel</button>
</p>
);
case "Complete":
return (
<p>
Download complete! :){" "}
<button onClick={startDownloading}>Download again because yes</button>
</p>
);
case "Error":
return (
<p>
Error while downloading: {downloadStatus.errorMessage}.{" "}
<button onClick={startDownloading}>Retry</button>
</p>
);
default:
return assertNever(downloadStatus);
}
};

In all honesty, I hope this will become unnecessary in most React-related contexts as soon as Suspense is released. React is supposed to then be able to handle all effects and race conditions under the hood.


Profile picture

I'm a Software Engineer from Catalonia based in North Carolina. I'm generally busy with work and 3 boys, but I'll try to drop some thoughts here when I can.

You can also follow me on Blueky, Twitter, LinkedIn and GitHub.

© 2024, Kilian Cirera Sant