Side-effects are painful and should be avoided, but as pain itself, they are an essential part of life if you are a web application. The most common side-effect by far is an HTTP request. One can think of many reasons why aborting an HTTP request might be a good idea:
- change of plans -- don't download that big file, don't send that sensitive email
- vitual scrolling, when in a few milliseconds we could be sending a few requests, it would be great to gain some bandwidth by only ever having a single active request
- similarly, in an auto-complete component, where each keystroke may be initiating a request, though we only ever care about the latest one
In some of these cases, there is also the possibility of race conditions, where request A completes after request B. If we don't put some guards, this will lead to unexpected states.
Consider also the case where a state object that is reset to its initial state while a side-effect was ongoing: when the side-effect completes, it might have invalid expectations about the current state and fail, or update the state object into an undesired state.
Of course, you can also guard against this with TypeScript, trying to make invalid states impossible, but this is often harder than it seems and doesn't scale because you end up having to create type guard functions to identify the current state type.
You could also be tempted to add something to your state to check if it's
active or not. Something like isEnabled
. This can work in some cases, but
only if you know for sure that your state will always be expected to be
mounted, like in a single-store application. But even in that case, I'd argue
it's a bad practice that will make different parts of your state more tightly
coupled, and your state management dependant on conditions outside of its
domain. Short-lived stores are more common than you might think for large
applications that don't want to fully migrate to a single framework, or that
are transitioning.
In such cases something like isEnabled
could prevent you from noticing
possible memory leaks. After all, if you are checking isEnabled
it's because
you know you may be holding reference to this state when it should possibly be
unmounted. This is akin to isMounted
, a method that was removed from React for
similar reasons.
Quoting the React team, from 2015:
An optimal solution would be to find places where
setState()
might be called after a component has unmounted, and fix them. Such situations most commonly occur due to callbacks, when a component is waiting for some data and gets unmounted before the data arrives. Ideally, any callbacks should be canceled incomponentWillUnmount
, prior to unmounting.
And in reality this is exactly the same problem we are discussing here.
The article goes on to recommend cancelable Promise
s, which works, except in
the most common case, which we agreed is HTTP requests.
Aborting XMLHttpRequest
is possible, but the spec is vague in terms of how
the network operations are handled or in case of a race condition between
aborting and the completion of the request.
For a while it was not possible to abort fetch
requests, and it wasn't
uncommon to resort to libraries like axios,
which got the job done. The
situation has improved for modern,
up-to-date browsers
(except Safari!), with
AbortController
.
Resorting to some
polyfills it should be possible to abort
fetch
requests in most use cases.
We don't know what the future will look like in terms of standards around
Promise
s in general, and the result of
past
discussions and
proposals is not
encouraging, but thankfully it's possible to use AbortController
to cancel
promises just like with fetch
requests.
To understand how AbortController
works, we should look at the few actors
involved in aborting a side-effect:
- the controller: an instance of
AbortController
. We pass itssignal
prop to any side-effect we wantaborted
when we callabort()
on the controller. - the signal: an instance of
AbortSignal
tied to a controller, passed to side-effect functions. It emitsabort
events whenabort()
is called on its origin controller. - the side-effect: it receives a signal and listens for its
abort
event to know when to abort its work, after which it throws an abort error. - the abort error: a
DOMException
instance with nameAbortError
.
The following example shows all actors in action to allow a user to cancel the download of a large video (adapted from a full working example).
js
const controller = new AbortController();const signal = controller.signal;const downloadBtn = document.querySelector('.download');const abortBtn = document.querySelector('.abort');const reports = document.querySelector('.reports');downloadBtn.addEventListener('click', fetchVideo);abortBtn.addEventListener('click', () => {controller.abort();console.log('Download aborted');});async function fetchVideo() {try {const response = await fetch(url, {signal});// do something with the response.} catch (e) {reports.textContent = `Download error: ${e.message}`;}}
We don't get to see what fetch
is doing internally, but as per the
spec:
- it checks that the signal has not been aborted yet
- it starts listening for
abort
events on the signal
When it detects that the signal has been aborted, either at the beginning or later through the event listener, it throws an abort error.
Let's see what our own cancelable side-effect function would look like, also based on examples from the spec:
js
function doAmazingness({signal}) {if (signal.aborted) {return Promise.reject(new DOMException('Aborted', 'AbortError'));}return new Promise((resolve, reject) => {// Begin doing amazingness, and call resolve(result) when done.// But also, watch for signals:signal.addEventListener('abort', () => {// Stop doing amazingness, and:reject(new DOMException('Aborted', 'AbortError'));});});}
One advantage of this approach is that we can cancel multiple requests with one
abort
call. Let's change the fetchVideo
function from the earlier example
so that it can download and abort two video downloads.
js
async function fetchVideos() {try {const video1 = await fetch(url1, {signal});const video2 = await fetch(url2, {signal});// do something with the response.} catch (e) {reports.textContent = `Download error: ${e.message}`;}}
Both requests will be aborted, but only one exception will be thrown.
In my next post I'll explain how to abort side effects this safely inside a React component.