Skip to content

Lifecycle

Use these callback patterns to set up and tear down islands cleanly. The core idea: create one AbortController in connectedCallback, pass its signal to every listener and fetch call, and abort it in disconnectedCallback.

Callbacks at a glance

CallbackWhenUse for
constructorElement createdsuper(), reading own static attributes, simple property defaults
connectedCallbackInserted into DOMAbortController, event listeners, observers, fetch, DOM queries
disconnectedCallbackRemoved from DOMthis.controller?.abort(), clearing timers

The AbortController pattern

js
class MyIsland extends window.HTMLElement {
  connectedCallback() {
    this.controller = new AbortController()
    const { signal } = this.controller

    // All addEventListener calls use signal
    this.addEventListener('click', this.onClick.bind(this), { signal })
    document.addEventListener('keydown', this.onKeyDown.bind(this), { signal })
    window.addEventListener('scroll', this.onScroll.bind(this), { signal })

    this.loadData()
  }

  disconnectedCallback() {
    // One call tears down ALL listeners and in-flight fetches
    this.controller?.abort()
  }

  async loadData() {
    try {
      const response = await fetch(this.dataset.url, {
        signal: this.controller.signal,
      })
      this.innerHTML = await response.text()
    } catch (e) {
      if (e.name !== 'AbortError') console.error(e)
    }
  }

  onClick(event) { /* ... */ }
  onKeyDown(event) { /* ... */ }
  onScroll() { /* ... */ }
}

window.customElements.define('my-island', MyIsland)

One controller handles all listeners and fetch calls. When disconnectedCallback fires, this.controller.abort() tears down everything at once — no need to track individual references or call removeEventListener.

Multiple controllers for event system listeners

When using listen() from @/lib/events, each call returns its own AbortController:

js
import { Events, listen } from '@/lib/events'

connectedCallback() {
  this.controller = new AbortController()

  // DOM listeners use the component controller
  this.addEventListener('keyup', this.onKeyUp.bind(this), {
    signal: this.controller.signal,
  })

  // Event system listeners return their own controllers
  this.eventControllers = [
    listen(document, Events.CART_ADDED, this.onCartAdded.bind(this)),
    listen(document, Events.CART_UPDATED, this.onCartUpdated.bind(this)),
  ]
}

disconnectedCallback() {
  this.controller?.abort()
  for (const c of this.eventControllers ?? []) {
    c.abort()
  }
}

Signal on fetch calls

Always pass signal: this.controller.signal to fetch(). Without it, a fetch that completes after the component is removed will try to update DOM elements that no longer exist.

js
async updateQuantity(line, quantity) {
  try {
    const response = await fetch(window.routes.cart_change_url, {
      ...fetchConfig(),
      body: JSON.stringify({ line, quantity }),
      signal: this.controller.signal,
    })
    const data = await response.json()
    // handle success
  } catch (e) {
    if (e.name === 'AbortError') return
    // handle real errors
  }
}

Always check for AbortError — it's expected when a component unmounts during a fetch, not a bug.

Constructor rules

The constructor runs before the element is in the DOM. Only use it for super() and static property reads:

js
constructor() {
  super()
  this.form = this.querySelector('form')
  this.hasCartDrawer = !!document.querySelector('cart-drawer')
}

this.querySelector() works because the browser upgrades elements after children are parsed. But document.querySelector() for elements outside the component is fragile — they may not exist yet. Move document-level queries to connectedCallback.

Anti-patterns

Listeners in the constructor

js
// BAD — no cleanup path, fires before element is in DOM
constructor() {
  super()
  this.addEventListener('click', this.onClick)
}

Move all listeners to connectedCallback with AbortController.

Missing disconnectedCallback

js
// BAD — listeners and fetches leak when element is removed
connectedCallback() {
  this.controller = new AbortController()
  window.addEventListener('scroll', this.onScroll, {
    signal: this.controller.signal,
  })
}
// No disconnectedCallback

Always implement disconnectedCallback. Shopify's Section Rendering API frequently replaces HTML, removing and re-inserting elements.

Fetch without signal

js
// BAD — fetch continues after component removal
async loadData() {
  const response = await fetch(this.dataset.url)
  this.innerHTML = await response.text()
}

Always pass { signal: this.controller.signal }.

Real-world example: CartDrawer

js
import { trapFocus, removeTrapFocus } from '@/lib/a11y'
import { Events, listen } from '@/lib/events'

class CartDrawer extends window.HTMLElement {
  connectedCallback() {
    this.controller = new AbortController()
    const { signal } = this.controller

    this.addEventListener(
      'keyup',
      (evt) => evt.code === 'Escape' && this.close(),
      { signal }
    )
    this.querySelector('#CartDrawer-Overlay').addEventListener(
      'click',
      this.close.bind(this),
      { signal }
    )
    this.setHeaderCartIconAccessibility()

    this.eventControllers = [
      listen(document, Events.CART_ADDED, this.onCartAdded.bind(this)),
      listen(document, Events.CART_UPDATED, this.onCartUpdated.bind(this)),
    ]
  }

  disconnectedCallback() {
    this.controller?.abort()
    for (const c of this.eventControllers ?? []) {
      c.abort()
    }
  }

  // ... methods
}

Next steps