AbortController API: A Modern Approach to JavaScript Lifecycle Events

This article was collaboratively authored by Whitespectre team members Guillermo A. Caserotto, Benjamin Calace, and Alex Naumchuk.

At Whitespectre, we strongly believe that the best code is the code that doesn't need to be written in the first place. However, we also understand that there are situations where writing code is necessary. In these cases, we strive to create code that is highly maintainable and minimizes the chances of errors. Therefore we are always looking for new ways of developing the code that will be simpler and better.

Modern JavaScript frameworks immensely simplified DOM and event interactions. Developers no longer need to work directly with browser APIs to the same extent as before. But there are still cases where that interaction is needed to utilize certain browser events, for example, mouse move, window resize, and so on. 

Removing event listeners is even more important in reactive frameworks since without proper handling each render would bind another event listener and would lead to multiple processing and in some cases may have a negative impact on the application performance. 

Let’s take a situation when we need to implement some kind of cursor tracking like a simple drag and drop. The simplest implementation would require setting up a mousedown event listener. For performance reasons it would make sense to bind other mouse events only once mouse is down. It also would prevent us from needing a state variable to keep track of the mouse button state. And finally - we need to remove the mousemove event listener when the mouseup event is fired. 

Additionally, you'll need to add proper error handling and remove the event listeners which can involve a lot of boilerplate code.

In order to remove the event listener you need to provide the associated event name and the same listener function reference that was used to bind the event.

const handleMouseDown = (event) => {
    // ...
}
element.addEventListener("mousedown", handleMouseDown);
// to remove it, we need to call removeEventListener with the same parameters used on addEventListener
element.removeEventListener("mousedown", handleMouseDown);

It’s quite easy to spot the problem - you can’t use anonymous functions and need to have named functions be available in the scope of the function that will take care of removing listeners.

Fortunately, modern web development offers a solution that simplifies the process of canceling requests or event listeners in progress. The AbortController and AbortSignal APIs, introduced in the ECMAScript 2017 (ES8) specification for JavaScript, enable you to stop ongoing operations without needing to manually mark the associated functions or events as canceled. 

With these APIs, you can improve the performance and responsiveness of your web applications, particularly in scenarios where multiple operations are executed concurrently and need to be canceled consistently.

At the time of writing this article, AbortController is supported in most modern browsers.

Today we will cover:

Event Listeners

Let’s say we wanted to implement a simple drag and drop for a div. A very barebones implementation could look like this:

 const elem = document.querySelector('div');
 const onMouseMove = (e: MouseEvent) => {
    const { clientX, clientY } = e;
    if (elem) {
      elem.style.left = clientX + 'px';
      elem.style.top = clientY + 'px';
    }
 }
 
 const onMouseUp = () => {
    window.removeEventListener('mousemove', onMouseMove);
    window.removeEventListener('mouseup', onMouseUp);
 }
 
 const onMouseDown = () => {
    window.addEventListener('mousemove', onMouseMove);
    window.addEventListener('mouseup', onMouseUp);
 }
 
 elem?.addEventListener('mousedown', onMouseDown);

There are a couple of issues with this:

  • For each event we need to keep track of our own event handler functions (for example, onMouseMove) to be passed on both the addEventListener and removeEventListener functions. Not keeping the reference to these functions will make it so we can’t remove the listener.
  • If we are listening to many events, we need to keep track of each one separately, and failing to remove the listener on some may result in performance issues or leaks.
  • On a syntax level, we are forced to use multiple lines for removing listeners, when this could be a simple arrow function using AbortController.

Luckily, addEventListener now supports a signal parameter as well. Using AbortController, we could implement a similar draggable element like:

 const elem = document.querySelector("div");
 elem?.addEventListener("mousedown", (e) => {
    const controller = new AbortController();
    const signal = controller.signal;
    const { offsetX, offsetY } = e;
 
    window.addEventListener(
      "mousemove",
      (e) => {
        elem.style.left = e.pageX - offsetX + "px";
        elem.style.top = e.pageY - offsetY + "px";
      },
      { signal }
    );
 
    window.addEventListener("mouseup", () => controller.abort(),          { signal });
 });

Then we can use a simple () => controller.abort() to remove all event listeners at once. This comes particularly handy when dealing with multiple listeners. We also don’t need to keep track of all the events we’re listening to at any given time. This makes it much harder to have memory leaks due to unremoved listeners.

Now let’s consider another example, using React’s useEffect hook. In this case, we want to run an onScroll function when the window is scrolled. The idea here is that when the user’s scrolling reaches a video container, only then it starts loading:

const videoContainerRef = useRef<HTMLDivElement>(null);
const [loadVideo, setLoadVideo] = useState(false);
 
useEffect(() => {
    function onScroll(e) {
  	 const rect = videoContainerRef.current?.getBoundingClientRect();
      if (rect && rect.top < window.innerHeight) {
        setLoadVideo(true);
        window.removeEventListener('scroll', onScroll);
      }
    }
 
    window.addEventListener('scroll', onScroll);
    return () => window.removeEventListener('scroll', onScroll);
 
}, []);

Notice that we remove the eventListener on the effect’s return. This will be called either when the effect is run again, or when the component is unmounted.

Now let’s see the same example using AbortController:

const videoContainerRef = useRef<HTMLDivElement>(null);
const [loadVideo, setLoadVideo] = useState(false);
 
useEffect(() => {
    const controller = new AbortController();
 
    function onScroll(e) {
      const rect = videoContainerRef.current?.getBoundingClientRect();
      if (rect && rect.top < window.innerHeight) {
        setLoadVideo(true);
        controller.abort();
      }
    }
 
    window.addEventListener('scroll', onScroll, { signal: controller.signal });
    return () => controller.abort();
}, []);

While at first glance there’s not much of a difference between these two approaches, once we start adding up event handlers, it becomes much easier to just use AbortController, saving us from declaring a function dedicated to removing listeners.

Other uses

You can also use AbortController on most modern browsers to control all kinds of asynchronous events, for example in:

  • Fetch
  • Animations
  • Timeouts
  • Websockets

Fetch

AbortSignal was added to the fetch() method with the release of the ES2018 specification. Similar to the previous xhr.abort(), the main use behind this is to be able to cancel an ongoing request if a condition is met, be it a timeout, or a user input.

To use this, simply add the signal to the fetch options:

const controller = new AbortController();
const signal = controller.signal;
fetch(url, { signal })
  .then(response => {
	// handle response
  })
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log(error); // "DOMException: The operation was aborted."
    }
	// handle error
});
 
// on user input, or when a condition is met
controller.abort();
 

Calling controller.abort() here cancels the request, and the promise returned by fetch is rejected accordingly.

Animations

Another useful application of AbortController involves using the controller.signal to cancel an animation:

    const controller = new AbortController();
    const signal = controller.signal;
 
    const box = document.getElementById('box');
 
    // Start the animation
    box.style.animationPlayState = 'running';
 
    // Stop the animation after 5 seconds
    const timeoutId = setTimeout(() => {
        controller.abort();
    }, 5000);
 
    // Cancel the animation if the cancel button is clicked
    const cancelButton = document.getElementById('cancel-button');
    cancelButton.addEventListener('click', () => {
        controller.abort();
    });
 
    // Handle the result of the animation
    signal.addEventListener('abort', () => {
        box.style.animationPlayState = 'paused';
    });

Conclusion

Implementing certain functionalities as we’ve shown above using old JavaScript code can require binding several functions to events and adding error handling, which can lead to a lot of boilerplate code. This not only increases the size of the code but also makes it difficult to read and maintain.

The AbortController provides a simple and effective way to cancel any type of asynchronous task, thus avoiding the need to add unnecessary and repeated code. This not only simplifies the code but also improves its readability and maintainability, making it easier for developers to work with and update it over time. Therefore, it is highly recommended that developers take advantage of this feature in their modern JavaScript applications.


Let’s Chat