Using AbortController as an Alternative for Removing Event Listeners

Avatar of Carter Li
Carter Li on

The idea of an “abortable” fetch came to life in 2017 when AbortController was released. That gives us a way to bail on an API request initiated by fetch() — even multiple calls — whenever we want.

Here’s a super simple example using AbortController to cancel a fetch() request:

const controller = new AbortController();
const res = fetch('/', { signal: controller.signal });
controller.abort();
console.log(res); // => Promise(rejected): "DOMException: The user aborted a request"

You can really see its value when used for a modern interface of setTimeout. This way, making a fetch timeout after, say 10 seconds, is pretty straightforward:

function timeout(duration, signal) {
  return new Promise((resolve, reject) => {
    const handle = setTimeout(resolve, duration);
    signal?.addEventListener('abort', e => {
      clearTimeout(handle);
      reject(new Error('aborted'));
    });
  });
}

// Usage
const controller = new AbortController();
const promise = timeout(10000, controller.signal);
controller.abort();
console.log(promise); // => Promise(rejected): "Error: aborted"

But the big news is that addEventListener now accepts an Abort Signal as of Chrome 88. What’s cool about that? It can be used as an alternate of removeEventListener:

const controller = new AbortController();
eventTarget.addEventListener('event-type', handler, { signal: controller.signal });
controller.abort();

What’s even cooler than that? Well, because AbortController is capable of aborting multiple cancelable requests at once, it streamlines the process of removing multiple listeners in one fell swoop. I’ve already found it particularly useful for drag and drop.

Here’s how I would have written a drag and drop script without AbortController, relying two removeEventListener instances to wipe out two different events:

// With removeEventListener
el.addEventListener('mousedown', e => {
  if (e.buttons !== 1) return;

  const onMousemove = e => {
    if (e.buttons !== 1) return;
    /* work */
  }

  const onMouseup = e => {
    if (e.buttons & 1) return;
    window.removeEventListener('mousemove', onMousemove);
    window.removeEventListener('mouseup', onMouseup);
  }

  window.addEventListener('mousemove', onMousemove);
  window.addEventListener('mouseup', onMouseup); // Can’t use `once: true` here because we want to remove the event only when primary button is up
});

With the latest update, addEventListener accepts the signal property as its second argument, allowing us to call abort() once to stop all event listeners when they’re no longer needed:

// With AbortController
el.addEventListener('mousedown', e => {
  if (e.buttons !== 1) return;

  const controller = new AbortController();

  window.addEventListener('mousemove', e => {
    if (e.buttons !== 1) return;
    /* work */
  }, { signal: controller.signal });

  window.addEventListener('mouseup', e => {
    if (e.buttons & 1) return;
    controller.abort();
  }, { signal: controller.signal });
});

Again, Chrome 88 is currently the only place where addEventListener officially accepts an AbortSignal. While other major browsers, including Firefox and Safari, support AbortController, integrating its signal with addEventListener is a no go at the moment… and there are no signals (pun sorta intended) that they plan to work on it. That said, a polyfill is available.