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.