Don't Sleep on AbortController

Today, I'd like to talk about one of the standard JavaScript APIs you are likely sleeping on. It's called AbortController.

What is AbortController?

AbortController is a global class in JavaScript that you can use to abort, well, anything!

Here's how you use it:

1const controller = new AbortController()

Once you create a controller instance, you get two things:

  • The signal property, which is an instance of AbortSignal. This is a pluggable part you can provide to any API to react to an abort event, and implement it accordingly. For example, providing it to a fetch() request will abort the request;
  • The .abort() method that, when called, triggers the abort event on the signal. It also updates the signal to be marked as aborted.

So far so good. But where is the actual abort logic? That's the beauty—it's defined by the consumer. The abort handling comes down to listening to the abort event and implementing the abort in whichever way is suitable for the logic in question:

1controller.signal.addEventListener('abort', () => {

Let's explore the standard JavaScript APIs that support AbortSignal out of the box.

Usage

Event listeners

You can provide an abort signal when adding an event listener for it to be automatically removed once the abort happens.

1const controller = new AbortController()

3window.addEventListener('resize', listener, { signal: controller.signal })

Calling controller.abort() removes the resize listener from the window. That is an extremely elegant way of handling event listeners because you no longer need to abstract the listener function just so you can provide it to .removeEventListener().

5const controller = new AbortController()

6window.addEventListener('resize', () => {}, { signal: controller.signal })

An AbortController instance is also much nicer to pass around if a different part of your application is responsible for removing the listener.

A great "aha" moment for me was when I realized you can use a single signal to remove multiple event listeners!

2 const controller = new AbortController()

4 window.addEventListener('resize', handleResize, {

5 signal: controller.signal,

7 window.addEventListener('hashchange', handleHashChange, {

8 signal: controller.signal,

10 window.addEventListener('storage', handleStorageChange, {

11 signal: controller.signal,

In the example above, I'm adding a useEffect() hook in React that introduces a bunch of event listeners with different purpose and logic. Notice how in the clean up function I can remove all of the added listeners by calling controller.abort() once. Neat!

Fetch requests

The fetch() function supports AbortSignal as well! Once the abort event on the signal is emitted, the request promise returned from the fetch() function will reject, aborting the pending request.

1function uploadFile(file: File) {

2 const controller = new AbortController()

6 const response = fetch('/upload', {

9 signal: controller.signal,

12 return { response, controller }

Here, the uploadFile() function initiates a POST /upload request, returning the associated response promise but also a controller reference to abort that request at any point. This is handy if I need to cancel that pending upload, for example, when the user clicks on a "Cancel" button.

Requests issued by the http module in Node.js also support the signal property!

The AbortSignal class also comes with a few static methods to simplify request handling in JavaScript.

AbortSignal.timeout

You can use the AbortSignal.timeout() static method as a shorthand to create a signal that dispatches the abort event after a certain timeout duration has passed. No need to create an AbortController if all you want is to cancel a request after it exceeds a timeout:

4 signal: AbortSignal.timeout(3000),

AbortSignal.any

Similar to how you can use Promise.race() to handle multiple promises on a first-come-first-served basis, you can utilize the AbortSignal.any() static method to group multiple abort signals into one.

1const publicController = new AbortController()

2const internalController = new AbortController()

4channel.addEventListener('message', handleMessage, {

5 signal: AbortSignal.any([publicController.signal, internalController.signal]),

In the example above, I am introducing two abort controllers. The public one is exposed to the consumer of my code, allowing them to trigger aborts, resulting in the message event listener being removed. The internal one, however, allows me to also remove that listener without interfering with the public abort controller.

If any of the abort signals provided to the AbortSignal.any() dispatch the abort event, that parent signal will also dispatch the abort event. Any other abort events past that point are ignored.

Streams

You can use AbortController and AbortSignal to cancel streams as well.

1const stream = new WritableStream({

2 write(chunk, controller) {

3 controller.signal.addEventListener('abort', () => {

9const writer = stream.getWriter()

The WritableStream controller exposes the signal property, which is the same old AbortSignal. That way, I can call writer.abort(), which will bubble up to the abort event on controller.signal in the write() method in the stream.

Making anything abortable

My favorite part about the AbortController API is that it's extremely versatile. So much so, that you can teach your any logic to become abortable!

With such a superpower at your fingertips, not only you can ship better experiences yourself, but also enhance how your are using third-party libraries that don't support aborts/cancellations natively. In fact, let's do just that.

Let's add the AbortController to Drizzle ORM transactions so we are able to cancel multiple transactions at once.

1import { TransactionRollbackError } from 'drizzle-orm'

3function makeCancelableTransaction(db) {

4 return (callback, options = {}) => {

5 return db.transaction((tx) => {

6 return new Promise((resolve, reject) => {

8 options.signal?.addEventListener('abort', async () => {

9 reject(new TransactionRollbackError())

12 return Promise.resolve(callback.call(this, tx)).then(resolve, reject)

The makeCancelableTransaction() function accepts a database instance and returns a higher-order transaction function that now accepts an abort signal as an argument.

In order to know when the abort happened, I am adding the event listener for the "abort" event on the signal instance. That event listener will be called whenever the abort event is emitted, i.e. when controller.abort() is called. So when that happens, I can reject the transaction promise with a TransactionRollbackError error to rollback that entire transaction (this is synonymous to calling tx.rollback() that throws the same error).

Now, let's use it with Drizzle.

1const db = drizzle(options)

3const controller = new AbortController()

4const transaction = makeCancelableTransaction(db)

10 .set({ balance: sql`${accounts.balance} - 100.00` })

11 .where(eq(users.name, 'Dan'))

14 .set({ balance: sql`${accounts.balance} + 100.00` })

15 .where(eq(users.name, 'Andrew'))

17 { signal: controller.signal }

I am calling the makeCancelableTransaction() utility function with the db instance to create a custom abortable transaction. From this point on, I can use my custom transaction as I normally would in Drizzle, performing multiple database operations, but I can also provide it with an abort signal to cancel all of them at once.

Abort error handling

Every abort event is accompanied with the reason for that abort. That yields even more customizability as you can react to different abort reasons differently.

The abort reason is an optional argument to the controller.abort() method. You can access the abort reason in the reason property of any AbortSignal instance.

1const controller = new AbortController()

3controller.signal.addEventListener('abort', () => {

4 console.log(controller.signal.reason)

8controller.abort('user cancellation')

The reason argument can be any JavaScript value so you can pass strings, errors, or even objects.

Conclusion

If you are creating libraries in JavaScript where aborting or cancelling operations makes sense, I highly encourage you to look no further than the AbortController API. It's incredible! And if you are building applications, you can utilize the abort controller to a great effect when you need to cancel requests, remove event listeners, abort streams, or teach any logic to be abortable.

Afterword

Special thanks to Oleg Isonen for proofreading this piece!

Accueil - Wiki
Copyright © 2011-2024 iteam. Current version is 2.137.1. UTC+08:00, 2024-11-15 10:07
浙ICP备14020137号-1 $Carte des visiteurs$