Web Components Guide
Beta

Using Cancellation Signals

Long running tasks, asynchronous tasks, and operations that have a set up and tear down phase, can make use of a concept called cancellation signals. If you've used other programming languages you might be familiar with objects like CancellationToken (or Go's context) - JavaScript's equivalent is AbortSignal which can be operated with an AbortController.

The AbortController & AbortSignal APIs can help manage operational "life time". Some examples:

AbortSignal AbortController Pattern

Cancellation Signals get given to APIs so they know when to abort. An AbortSignal is created by a controller (new AbortSignal() will throw an error). Controllers allow you to make the decision of when a Cancellation Signal changes. Creating an AbortController will also create a new AbortSignal, accessible via .signal. Code that has access to the controller can decide when it should be aborted (by calling .abort()), while code that has access to the signal can be notified of the abort. To make a new AbortController, call new AbortContoller(). The constructor takes no arguments.

An AbortSignals .aborted property will be true if the signal has been aborted - you can periodically check that to stop any work that is about to be done. AbortSignal is also an EventTarget - it emits an abort event which you can listen to and invoke your tear down.

You can also create some basic controller-free signals that follow some common patterns. For example AbortSignal.timeout(1000) will create a cancellation signal that aborts after 1000 milliseconds. These controller-free signals cannot be manually aborted. You can combine controller-free and controllable signals with AbortSignal.any([...signals]).

Using Cancellation Signals internally manage your private APIs

Cancellation Signals can be used to manage internal state that you might have. You can create an AbortController as part of your private state, and make use of signals to control behavior. Consumers of your component won't pass these signals to you, instead you can use them to track a tasks state internally.

A component with start() and stop() functions can make the stop() function abort the controller, and the start() function create the controller, while checking if the signal has been aborted during an asynchronous loop like so:

class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  shadowRoot = this.attachShadow({ mode: "open" })

  // Set up the class field, but set it to null for now
  #startStopController = null

  async start() {
    // Stop the current task if there was one
    this.stop()

    // Create a new internal controller, and get the signal
    this.#startStopController = new AbortController()
    const signal = this.#startStopController.signal

    // Loop until `signal.aborted` is `true`.
    while (!signal.aborted) {
      // Automatically stop when this element is disconnected
      if (!this.isConnected) return

      const milliseconds = Date.now() - this.#start
      const minutes = String(Math.floor(milliseconds / (1000 * 60))).padStart(2, "0")
      const seconds = String(Math.floor((milliseconds / 1000) % 60)).padStart(2, "0")
      const hundredths = String(Math.floor((milliseconds % 1000) / 10)).padStart(2, "0")
      this.shadowRoot.replaceChildren(`${minutes}:${seconds}:${hundredths}`)

      // Schedule next update by awaiting an animation frame
      await new Promise((resolve) => requestAnimationFrame(resolve))
    }
  }

  stop() {
    // Stop aborts the startStopController if it exists
    this.#startStopController?.abort()
  }
}

Using Cancellation Signals in your own public APIs

If you can use a signal as part of your internal state, it might be simpler to add it as part of the public API. If you are considering using cancellation signals in a public API, it's a good idea to make them an optional part of your API as they won't always be needed.

A component using cancellation signals no longer needs separate start & stop methods, instead combining into one and relying on the signal to know when to stop. This can often simplify code as there's no need to track state across different methods.

class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  shadowRoot = this.attachShadow({ mode: "open" })

  async start({ signal } = {}) {
    // Loop until `signal.aborted` is `true`.
    // If `signal` doesn't exist, then loop forever.
    // Uses the optional chaining operator to safely check if signal exists
    while (!signal?.aborted) {
      // Automatically stop when this element is disconnected
      if (!this.isConnected) return

      const milliseconds = Date.now() - this.#start
      const minutes = String(Math.floor(milliseconds / (1000 * 60))).padStart(2, "0")
      const seconds = String(Math.floor((milliseconds / 1000) % 60)).padStart(2, "0")
      const hundredths = String(Math.floor((milliseconds % 1000) / 10)).padStart(2, "0")
      this.shadowRoot.replaceChildren(`${minutes}:${seconds}:${hundredths}`)

      // Schedule next update by awaiting an animation frame
      await new Promise((resolve) => requestAnimationFrame(resolve))
    }
  }
}

Combining multiple Cancellation Signals

It's possible to combine multiple sources of cancellation signals - for example combining internal and external cancellation signals to allow for multiple flavors of API. Two or more cancellation signals can be joined into one using AbortSignal.any(), which creates a new signal that aborts when any of the given cancellation signals abort. It's similar to Promise.any(), but for AbortSignal.

A component can offer the more traditional start() and stop() APIs, as well allowing cancellation signals to be passed via start({ signal }). Making use of internal and external cancellation signals, with AbortSignal.any():

class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  shadowRoot = this.attachShadow({ mode: "open" })

  #startStopController = null

  async start({ signal } = {}) {
    // Stop the current task if there was one
    this.stop()

    // Create a new internal controller
    this.#startStopController = new AbortController()
    // Collect all valid signals
    const signals = [this.#startStopController.signal, signal].filter((s) => s)

    const signal = AbortSignal.any(signals)

    // Loop until `signal.aborted` is `true`.
    while (!signal.aborted) {
      // Automatically stop when this element is disconnected
      if (!this.isConnected) return

      const milliseconds = Date.now() - this.#start
      const minutes = String(Math.floor(milliseconds / (1000 * 60))).padStart(2, "0")
      const seconds = String(Math.floor((milliseconds / 1000) % 60)).padStart(2, "0")
      const hundredths = String(Math.floor((milliseconds % 1000) / 10)).padStart(2, "0")
      this.shadowRoot.replaceChildren(`${minutes}:${seconds}:${hundredths}`)

      // Schedule next update by awaiting an animation frame
      await new Promise((resolve) => requestAnimationFrame(resolve))
    }
  }

  stop() {
    this.#startStopController?.abort()
  }
}

Using Cancellation Signals to clean up disconnectedCallback()

Web Components that use the connectedCallback() lifecycle hook to set things up typically want to tear down those same things in the disconnectedCallback(), but this can sometimes get a little unwieldy. Instead of mirroring everything in disconnectedCallback(), using an AbortController can reduce disconnectedCallback() down to one line of code. APIs called in connectedCallback will get given the signal, and disconnectedCallback() only calls abort().

APIs like addEventListener accept a signal option. When an Event Listeners signal is aborted, the event listener will be removed (just like calling removeEventListener).

class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  // Set up the class field, but set it to null for now
  #connectedController = null

  connectedCallback() {
    // Make a new AbortController and extract the `signal` property
    const { signal } = (this.#connectedController = new AbortController())

    // Pass the signal to addEventListener
    this.ownerDocument.addEventListener("keypress", this, { signal })
    this.ownerDocument.addEventListener("mouseenter", this, { signal })
    this.ownerDocument.addEventListener("mouseleave", this, { signal })
  }

  disconnectedCallback() {
    // All cleanup happens with this one line
    this.#connectedController?.abort()

    // No need to do any of this:
    // this.ownerDocument.removeEventListener("keypress", this)
    // this.ownerDocument.removeEventListener("mouseenter", this)
    // this.ownerDocument.removeEventListener("mouseleave", this)
  }
}

Using Cancellation Signals to cancel old requests

A common task that components might do is turn a user action into a network fetch. For example a search input might query the database every time a character is pressed. If the user types into the input fast enough, old network requests might stay in-flight, saturating the network and delaying newer requests from coming in, making the component feel sluggish. A good way to combat this is to cancel stale requests by using cancellation signals:

class SearchInputElement extends HTMLInputElement {
  static define(tag = "search-input") {
    customElements.define(tag, this, { extends: "input" })
  }

  src = new URL("/search")

  connectedCallback() {
    this.addEventListener("input", this)
  }

  // Set up the class field, but set it to null for now
  #fetchController = null

  async handleEvent() {
    // Abort the old fetch, if the controller exists
    this.#fetchController?.abort()

    // Create a new one and extract the signal
    const { signal } = (this.#fetchController = new AbortContoller())

    const src = new URL(this.src)
    src.searchParams.add("q", this.value)

    // Perform the fetch, make sure to pass it the signal so it can be aborted
    try {
      const res = await fetch(src, { signal })
    } catch (error) {
      // An aborted network fetch will throw, so we should return early
      if (signal.aborted) return

      throw error
    }

    if (res.ok) {
      this.list.append(await res.text())
    }
  }
}