Dean Radcliffe
Posted on May 21, 2021
Motivation
Cancelation is first and foremost a User Experience issue. Users will perceive apps to be more responsive when resources like the network are freed up to be used by the task at hand. Cancelation is also a way to improve the experience of users on slower connections, so increases your app's reach.
The pictures below show how a search results page stops consuming network usage when it is unmounted (and how incremental rendering helps show results sooner- the topic of a future post)
With cancelation, and incremental delivery:
No cancelation (and no incremental delivery):
"But how often will this matter?" is a question you might ask. That will depend on your user demographics and their connectivity, of course. But you needn't wait for complaints to arrive to build in a sensible principle.
Comparisons
Cancelation wasn't always an afterthought. The first web browsers had a big red 'STOP' button so that users could cancel slow-loading pages at any time.
As the SPA era began, about 10 years later, several things became casualties as far as User Experience (UX). Yes, "the back button broke". But also - now that request-making and asynchronous processes were no longer tied to that big red button, fire-and-forget AJAX became the norm. Browsers stopped showing a Stop button (does yours have one?), and developers stopped treating cancelation like it was critical to good UX. But it still is.
Imagine if operating systems didn't cancel child processes by default! Web development only differs by degree from that.
Code Examples With React Hooks
So how does one achieve component-level cancelation in React with hooks? We'll explore several technologies' answers to this, in the context of a real-world example. Let's say we have a component that presents a list of possible appointment times, and uses a hook called useAppointments
to query a back-end via AJAX for whether the chosen time is available. Stripped of UI details, it would look like this:
function AppointmentChooser() {
const { beginTime, setBeginTime, isAvailable } = useAppointments();
return <>
<select
onChange={(e) => setBeginTime(e.target.value)}>
<!-- time options -->
</select>
<span>{beginTime} { isAvailable ? "✅" : "🚫" }
</>
}
Our goal will be that any effects this component triggers will be shutdown when this appointment chooser unmounts.
Style 1 — Vanilla JS, no cancelation
Here's how we might implement useAppointments
without regard to cancelation:
export function useAppointments() {
const [beginTime, _setBeginTime] = useState('');
const [isAvailable, setIsAvailable] = useState<null | 'loading' | true | false>(null);
function setBeginTime(time: string) {
setIsAvailable('loading');
_setBeginTime(time);
fetch(`https://httpbin.org/delay/5?t=${time}`)
.then(({ isAvailable }) => {
setIsAvailable(isAvailable);
});
};
return { beginTime, setBeginTime, isAvailable };
}
The job of the hook's setBeginTime
function is to 1) set the beginTime
in local state, 2) set the availability to 'loading'
3) perform the fetch, and 4) set the availability asynchronously with the result of the fetch. This function setBeginTime
is what we will focus on as we show differing implementations.
Style 1.1 — Vanilla JS: Promise + AbortController
In 2018 the AbortController
abstraction was introduced for canceling some Promises. An example of a hook that uses an AbortController on each request, and cancels the fetch upon on unmount is shown below.
export function useAppointments() {
const [beginTime, _setBeginTime] = useState('');
const [isAvailable, setIsAvailable] = useState<null | 'loading' | true | false>(null);
const ac = useRef<AbortController>(null);
useEffect(() => () => ac.current.abort(), []);
function setBeginTime(time: string) {
setIsAvailable('loading');
_setBeginTime(time);
ac.current = new AbortController();
fetch(`https://httpbin.org/delay/${delay}?t=${time}`, {
signal: ac.current.signal,
}).then(
() => {
setIsAvailable(true);
},
(ex: DOMException) => {
if (ex.name === 'AbortError') {
// not an exception
setIsAvailable(null);
} else { throw ex }
}
);
};
return { beginTime, setBeginTime, isAvailable };
}
Wow, that's a lot of code. We have to hold refs for AbortController instances. We have to use the cryptic React-hook-specific syntax to invoke cancelation, which reads, "on unmount, abort the current request".
useEffect(() => () => ac.current.abort(), [])
And then we have the exception-handling code. An aborted Promise is treated as an exception which generally will you will generally want to distinguish from a real exception like a 501
server error.
This code achieves cancelation-on-unmount with only Vanilla JS, but are there libraries we can use to have a simpler implementation?
A library exists that generalizes cancelable async processes, and will let us apply more concurrency options as transparently as cancelation. Let's see how RxJS, familiar to Angular users, approaches cancelation.
Style 2 — RxJS + useEffect
In 2012, before Promises were even integrated into JavaScript, the ReactiveX project (now known as RxJS) introduced an inherently cancelable datatype - Observable
. While Observable is more commonly known for its use as an asynchronous Stream, every Observable ever made is cancelable. Because of this, there will be far less code to cancel an Observable than a Promise.
The general strategy is to wrap the begin and end operations in an RxJS Observable explicitly, and return that Observable to a hook that will call .subscribe()
on it, and call .unsubscribe()
on it when unmounted.
import { Subscription } from 'rxjs';
import { ajax } from 'rxjs/ajax';
import { tap } from 'rxjs/operators';
export function useAppointments() {
const [beginTime, _setBeginTime] = useState('');
const [isAvailable, setIsAvailable] = useState<null | 'loading' | true | false>(null);
const process = useRef<Subscription>(null);
useEffect(() => () => process.current.unsubscribe(), []);
function setBeginTime(time: string) {
setIsAvailable('loading');
_setBeginTime(time);
process.current = ajax
.getJSON(`https://httpbin.org/delay/5?t=${time}`)
.pipe(tap({ isAvailable }) => {
setIsAvailable(isAvailable);
}))
.subscribe();
};
return { beginTime, setBeginTime, isAvailable };
}
Like the AbortController example, we need a ref
to keep track of the cancelation variable. But in this case it is a Subscription, not an AbortController, and cancelation function is unsubscribe()
, not abort()
. ajax.getJSON()
creates the Observable, which represents but does not start the AJAX call, and .subscribe()
begins the call, and returns the Subscription which is how we cancel. The pipe(tap(..))
construct updates the local state by calling setIsAvailable
once a value is available from the request.
The main increase in clarity here comes from the fact that unsubscribing from an Observable (or technically, from its Subscription) is not considered an exception, so that code disappears! But we still rely on managing an extra object - the Subscription - in order to provide cancelation. Let's now make those subscription objects disappear.
Style 3 - RxJS + useCancelableEffect
The polyrhythm
library, introduced in 2018, lowers the learning curve and amount of code required to use RxJS. The companion library polyrhythm-react
exports hooks for using it in a React context. Let's see how its useCancelableEffect
function can clean up our availability-querying hook:
import { useCancelableEffect } from 'polyrhythm-react';
import { ajax } from 'rxjs/ajax';
import { tap } from 'rxjs/operators';
export function useAppointments() {
const [beginTime, _setBeginTime] = useState('');
const [isAvailable, setIsAvailable] = useState<null | 'loading' | true | false>(null);
const [queryAvailability] = useCancelableEffect((time: string) => {
return ajax
.getJSON(`https://httpbin.org/delay/5?t=${time}`)
.pipe(tap({ isAvailable }) => {
setIsAvailable(isAvailable);
}));
});
function setBeginTime(time: string) {
setIsAvailable('loading');
_setBeginTime(time);
queryAvailability(time);
};
return { beginTime, setBeginTime, isAvailable };
}
This is the shortest listing yet. We provide useCancelableEffect
a function that converts a time
to an Observable of the AJAX query for availability and state-updating. useCancelableEffect
returns a tuple, the first item which is a triggering function, which we name queryAvailability
.
After updating local state in setBeginTime
, we call queryAvailability
to begin the AJAX, and if at any time the component unmounts, the AJAX call will terminate! The API to getting an Observable representing an AJAX request is very similar to getting a Promise for a request, but since Observables are inherently cancelable there's no extra AbortController. This results in less code overall.
We can further generalize the process of AJAX to include the loading state - which we don't want to leave displaying "loading" if the request has canceled. Here's how we use the Observable constructor directly to incorporate teardown into cancelation:
import { useCancelableEffect } from 'polyrhythm-react';
import { Observable } from 'rxjs';
import { ajax } from 'rxjs/ajax';
import { tap } from 'rxjs/operators';
export function useAppointments() {
const [beginTime, _setBeginTime] = useState('');
const [isAvailable, setIsAvailable] = useState<null | 'loading' | true | false>(null);
const [setBeginTime] = useCancelableEffect((time: string) => {
return new Observable(observer => {
setIsAvailable('loading');
_setBeginTime(value);
const query = ajax
.getJSON(`https://httpbin.org/delay/5?t=${time}`)
.pipe(tap({ isAvailable }) => {
setIsAvailable(isAvailable);
}));
const ajax = query.subscribe({
complete() { observer.complete(); }
});
return function teardown() {
ajax.unsubscribe();
setIsAvailable(null); // clear the loading state
}
});
return { beginTime, setBeginTime, isAvailable };
}
Like React's own useEffect
, the returned value from the new Observable
factory is a teardown function that is called upon unsubscribe. Upon teardown we should stop the AJAX, and revert the loading state to unknown aka null
. Upon starting the Observable, we simply need to set the loading state, and call subscribe
to begin the AJAX. This way, a single Observable represents the entire process of AJAX, including its loading state. The argument to subscribe
-containing observer.complete()
- indicates that the completion of the AJAX should mark the end of the entire Observable. This just illustrates one way to compose Observable behavior with cancelation, and is not meant to be prescriptive for all cases.
This technique generalizes far beyond AJAX, and makes RxJS a real workhorse. For example, to fire-off an auto-canceling Observable that uses the browser's Speech API:
const [speakIt] = useCancelableEffect(() => new Observable(() => {
const words = `Checking availability for ${time}`;
speechSynthesis.speak(new SpeechSynthesisUtterance(words));
return () => {
window.speechSynthesis.cancel();
};
});)
The ability to bundle cancelation with creation ultimately leads to optimally performant code, with fewer edge cases, and less scattering of logic. This is why RxJS is useful on the front-end, back-end, and is one of the most downloaded packages on NPM (over 2x that of React!)
Bonus — RxJS Operators via hooks
Note that in the examples above, the code assumes that there will be no overlapping requests. But in real life, if a user isn't getting a speedy response for one appointment time, they may choose another, and bugs will result!
RxJS provides operators to deal with the concurrency issue, but in fact RxJS users' most frequent sources of confusion are is how to choose and use operators.
For your convenience, all of the operators of RxJS have hooks in polyrhythm-react
which let you control timing with precision (this will be elaborated in a future post).
polyrhythm-react | RxJS |
---|---|
useCancelableEffect/useASAPEffect | mergeMap |
useQueuedEffect | concatMap |
useRestartingEffect | switchMap |
useThrottledEffect | exhaustMap |
Summary
It's a best practice all over the web development stack to tie processes to the things that need them, so they tear down automatically. On the back-end, don't do work for a client who's disconnected. On the front-end, when a component that just mounted did a thing, and the user navigated to a new route that causes an unmount.
The idea of the API to the hook remaining the same, while cancelation is used internally is the best-practice way to integrate cancelable processes to React. Whether you obtain that result with Redux Query, RxJS or custom hooks is up to you. But your users and support teams want you to reduce issues that can be fixed by cancelation. And development is easier when concurrency options prevent race conditions, which canceling enables. Now you have an array of tools to help improve User Experience.
Happy canceling!
Bonus - Redux Query useRequest
If the process we want to be cancelable is an AJAX request made with Redux Query, there is a hook that can help us. But first let's remind ourselves how a non-cancelable Redux Query hook looks.
In order to move the isAvailable
field to be controlled by R/Q, we introduce a queryConfig apptQueryConfig
that specifies where to locate the state in Redux, and a selector selectApptAvailability
that finds that state. In prod code, maybe we'd move the state field of beginTime
up to the Redux store, and out of this component as well, but for demo purposes we'll leave it.
So, for non-cancelable Redux Query we'd have:
import { useDispatch, useSelector } from 'react-redux';
import { requestAsync } from 'redux-query';
import { apptQueryConfig, selectAvailability } from './appointments';
export function useAppointments() {
const dispatch = useDispatch();
const [beginTime, _setBeginTime] = useState('');
const isAvailable = useSelector(selectApptAvailability);
// state as before ...
function setBeginTime(time: string) {
setIsAvailable('loading');
_setBeginTime(time);
// add time to the queryConfig
const queryConfig = apptQueryConfig(time);
// perform the lookup
dispatch(requestAsync());
}
return { beginTime, setBeginTime, isAvailable };
}
After adding time to the queryConfig, it is a simple dispatch
of a requestAsync
action which begins the AJAX, and resolves isAvailable
. How do we make it cancelable? There's a Redux Query hook for that: useRequest
. With useRequest
, we get cancelation almost 'for free'. According to R/Q docs:
When the associated component unmounts and there is a request in-flight, then a
cancelQuery
action will be dispatched which will attempt to abort the network request.
So we have the following:
import { useSelector } from 'react-redux';
import { useRequest } from 'redux-query-react';
import { apptQueryConfig, selectAvailability } from './appointments';
export function useAppointments() {
const [beginTime, _setBeginTime] = useState('');
const isAvailable = useSelector(selectApptAvailability);
// state as before ...
const queryConfig = useMemo(() => {
return beginTime ? apptQueryConfig(beginTime) : null;
}, [beginTime]);
useRequest(queryConfig);
function setBeginTime(time: string) {
setIsAvailable('loading');
_setBeginTime(time);
// R/Q auto-updates on changes of `beginTime`
}
return { beginTime, setBeginTime, isAvailable };
}
Nice! We have a queryConfig that is a memoized version of beginTime
. The queryConfig must be null
when beginTime
is not yet set, to accommodate the first render, since the user has not yet provided a time. It is this config that we pass to useRequest
, and cancelation happens behind the scene, easy peasy!
It's no surprise a popular library like Redux Query accounts for the cases of cancelation. In addition, can you see how it solves the multiple request problem? According to its docs:
If there is a previously-issued request that is still in-flight, then a
cancelQuery
action will be dispatched which will attempt to abort the network request.
In other words, when the user changes their mind and selects a new appointment time, all the network bandwidth goes toward the NEW appointment time- the previous one is canceled since we won't display its result anyway! This is the bedrock of good UX, and it reduces edge cases as well. We won't elaborate on concurrency here—a future post will. For now, note that cancelation isn't just useful for unmounting, but also for eliminating race conditions caused by previous requests completing after newer ones.
Posted on May 21, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.