Whistlr
@samthor

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:

Sorry, your browser doesn't support an inline demo.

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.

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?

  1. 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 another SomeObject, construct it again.

  2. You can pass a shared AbortSignal from somewhere else, and aborting SomeObject doesn't require you to hold SomeObject—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 global controller.abort() when it's done.

  3. If SomeObject does built-in operations like fetch(), you can simply pass down the AbortSignal 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.

function abortTimeout(ms) {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), ms);
  return controller.signal;
}
function abortAny(signals) {
  const controller = new AbortController();
  signals.forEach((signal) => {
    if (signal.aborted) {
      controller.abort();
    } else {
      signal.addEventListener('abort', () => controller.abort());
    }
  });
  return controller.signal;
}
  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. 🐦