AbortController is your friend
One of my favorite new features of JS is the humble AbortController
, and its AbortSignal
.
It enables some new development patterns, which I'll cover below, but first: the canonical demo.
It's to use AbortController
to provide a fetch()
you can abort early:
And here's a simplified version of the demo's code:
fetchButton.onclick = async () => {
const controller = new AbortController();
// wire up abort button
abortButton.onclick = () => controller.abort();
try {
const r = await fetch('/json', { signal: controller.signal });
const json = await r.json();
// TODO: do something 🤷
} catch (e) {
const isUserAbort = (e.name === 'AbortError');
// TODO: show err 🧨
// this will be a DOMException named AbortError if aborted 🎉
}
};
This example is important because it shows something that wasn't possible before AbortController
came along: that is, aggressively cancelling a network request.
The browser will stop fetching early, potentially saving the user's network bandwidth.
It doesn't have to be user-initiated, either: imagine you might Promise.race()
two different network requests, and cancel the one that lost. 🚙🚗💨
Great!
And while this is cool, AbortController
and the signal it generates actually enable a number of new patterns, which I'll cover here.
Read on. 👀
Prelude: Controller vs Signal
I've demonstrated constructing an AbortController
.
It provides an attached subordinate signal, known as AbortSignal
:
const controller = new AbortController();
const { signal } = controller;
Why are there two different classes here? Well, they serve different purposes.
-
The controller lets the holder abort its attached signal via
controller.abort()
. -
The signal can't be aborted directly, but you can pass it to calls like
fetch()
, or listen to its aborted state directly.You can check its state with
signal.aborted
, or add an event listener for the"abort"
event. (fetch()
is doing this internally—this is just if your code needs to listen to it.)
Put differently, the thing being aborted shouldn't be able to abort itself, hence why it only gets the AbortSignal
.
Use-Cases
Abort legacy objects
Some older parts of our DOM APIs don't actually support AbortSignal
.
One example is the humble WebSocket
, which only has a .close()
method you can call when done.
You might allow it to be aborted like this:
function abortableSocket(url, signal) {
const w = new WebSocket(url);
if (signal.aborted) {
w.close(); // already aborted, fail immediately
}
signal.addEventListener('abort', () => w.close());
return w;
}
This is pretty simple, but has a big caveat: note that AbortSignal
doesn't fire its "abort"
even if it's already aborted, so we actually have to check whether it's already finished, and .close()
immediately in that case.
As an aside, it is a bit bizzare to create a working WebSocket
here and immediately cancel it, but to do otherwise might break the contract with our caller, which expects to be returned a WebSocket
, just with the knowledge that it might be aborted at some point.
Immediately is a valid "some point", so that seems fine to me! 🤣
Removing Event Handlers
One particularly annoying part of learning JS and the DOM is the realization that event handlers and function references don't work this way:
window.addEventListener('resize', () => doSomething());
// later (DON'T DO THIS)
window.removeEventListener('resize', () => doSomething());
…the two callbacks are different objects, so the DOM, in its infinite wisdom—just fails to remove the callback silently, without an error. 🤦 (I actually think this is totally reasonable, but it is something that can trip up absolute novice developers.)
The net effect of this is that a lot of code that deals with event handlers just has to keep hold of the original reference, which can be a pain.
You can see where I'm going with this.
With AbortSignal
, you can simply get the signal to remove it for you:
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('resize', () => doSomething(), { signal });
// later
controller.abort();
Just like that, you've simplified event handler management!
⚠️ This doesn't work (and will fail silently) on some older Chrome versions, and Safari before 15, but this will go away as time moves on. You can check (and add a polyfill) using my code here.
Constructor pattern
If you're encapsulating some complex behavior in JavaScript, it can be non-obvious how to manage its lifecycle.
This matters for code which has a clear start and end point—let's say your code does a regular network fetch, or renders something to the screen, or even uses something like WebSocket
—all things that you want to start, do for a while, then stop. ✅➡️🛑
Traditionally you might write code like this:
const someObject = new SomeObject();
someObject.start();
// later
someObject.stop();
This is fine, but we can make it more ergonomic by accepting an AbortSignal
:
const controller = new AbortController();
const { signal } = controller;
const someObject = new SomeObject(signal);
// later
controller.abort();
Why do you want to do this?
-
This limits the
SomeObject
to only transition from start → stopped—never back to started again. This is opinionated, but I actually believe it simplifies building these kinds of objects—it's clear they're single-use, and are just done when the signal is aborted. If you want anotherSomeObject
, construct it again. -
You can pass a shared
AbortSignal
from somewhere else, and abortingSomeObject
doesn't require you to holdSomeObject
—a good example here is, let's say several bits of functionality are tied to a start/stop cycle, that stop button can just call the effectively globalcontroller.abort()
when it's done. -
If
SomeObject
does built-in operations likefetch()
, you can simply pass down theAbortSignal
even further! Everything it does can be externally stopped, and this is a way to ensure its world is being torn down properly.
Here's how you might use it:
export class SomeObject {
constructor(signal) {
this.signal = signal;
// do e.g., an initial fetch
const p = fetch('/json', { signal });
}
doComplexOperation() {
if (this.signal.aborted) {
// prevent misuse - don't do something complex after abort
throw new Error(`thing stopped`);
}
for (let i = 0; i < 1_000_000; ++i) {
// do complex thing a lot 🧠
}
}
}
This is showing off two ways to use the signal: one is to pass it to built-in methods which further accept it (case 3. above), and just checking whether calls are allowed (case 1. above) before doing something expensive.
Async work in (P)react hooks
Despite some recent controversy about what exactly you should do inside useEffect
, it's pretty clear that a lot of people do use it for fetching from the network.
And that's fine, but the typical pattern seems to be to make the callback do async
work.
And this is basically gambling. 🎲
Why? Well, because if your effect doesn't finish before it's fired again, you don't find that out—the effect just runs in parallel. Something like this:
function FooComponent({ something }) {
useEffect(async () => {
const j = await fetch(url + something);
// do something with J
}, [something]);
return <>...<>;
}
What you should do instead, is create a controller that you abort whenever the next useEffect
call runs:
function FooComponent({ something }) {
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
const p = (async () => {
// !!! actual work goes here
const j = await fetch(url + something, { signal });
// do something with J
})();
return () => controller.abort();
}, [something]);
return <>...<>;
}
Now that's a very simplified version that requires you to wrap your calls.
You might want to consider writing your own hook (e.g., useEffectAsync
), or using a library to help you.
However, remember that in hook-land, the lifecycle of what you have access to after your first await
call is really unclear—your code technically references the previous run.
Things like setting state tend to be fine, but getting state is not going to work:
function AsyncDemoComponent() {
const [value, setValue] = useState(0);
useEffectAsync(async (signal) => {
await new Promise((r) => setTimeout(r, 1000));
// What is "value" here?
// It's always going to be 0, the initial value, even if the button
// below was pressed.
}, []);
return <button onClick={() => setValue((v) => v + 1)}>Increment</button>
}
Anyway, that's a whole other post on React lifecycle gotchas.
Helpers that may or may not exist
There's a few helpers which may or may not be available by the time you read this post.
I've mostly demoed very simple use of AbortController
, including aborting it yourself.
AbortSignal.timeout(ms)
: This creates a soloAbortSignal
which will be automatically aborted after a given timeout. This is easy to create yourself if you need to:
function abortTimeout(ms) {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
AbortSignal.any(signals)
: This creates a signal which is aborted if any of the passed signals are aborted. Again, you can construct this youself—but watch out, if you pass no signals, the derived signal will never abort:
function abortAny(signals) {
const controller = new AbortController();
signals.forEach((signal) => {
if (signal.aborted) {
controller.abort();
} else {
signal.addEventListener('abort', () => controller.abort());
}
});
return controller.signal;
}
AbortSignal.throwIfAborted()
: This is a helper onAbortSignal
which throws an error if aborted—preventing you from constantly checking it. You use it like:
if (signal.aborted) {
throw new Error(...);
}
// becomes
signal.throwIfAborted();
This is harder to polyfill, but you could write a helper like:
function throwIfSignalAborted(signal) {
if (signal.aborted) {
throw new Error(...);
}
}
All done
That's it!
I hope that has been an interesting summary on AbortController
and AbortSignal
.
Follow me on Twitter for more sass. 🐦