Events and delegation in depth

The options object, capture and bubble, delegation with closest, removing listeners reliably, and custom events between components.

The third argument

const onChange = (event) => console.log(event.target.value);

input.addEventListener('input', onChange);

// capture: run on the way down instead of the way up
document.addEventListener('click', closeAllMenus, { capture: true });

// once: remove itself after the first call
button.addEventListener('click', submitOnce, { once: true });

// passive: promise you will not call preventDefault
window.addEventListener('scroll', onScroll, { passive: true });

// signal: remove with an AbortController instead of remembering the handler
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
window.addEventListener('scroll', onScroll, { signal: controller.signal });
controller.abort();        // removes both at once

// the old form still works
input.removeEventListener('input', onChange);
  • removeEventListener matches on type, handler identity and capture flag. A different function with the same body does not remove anything.
  • { passive: true } on scroll and touch listeners lets the browser scroll immediately instead of waiting to see whether you cancel - it is the difference between a smooth and a stuttering page.
  • An AbortController per component is the tidiest cleanup pattern: one abort() disposes every listener the component registered.
💡
The signal option is supported everywhere current, and it removes an entire category of bug - the listener that outlives the element it belonged to. If you register more than one listener in a component, give the component an AbortController and abort it on teardown.

Target, phases and propagation

list.addEventListener('click', (event) => {
  event.target;          // the element actually clicked
  event.currentTarget;   // the element this listener is attached to
  event.eventPhase;      // 1 capture, 2 at target, 3 bubble
  event.composedPath();  // the full path, through shadow boundaries

  event.preventDefault();       // cancel the default behaviour
  event.stopPropagation();      // stop after this listener, same node continues
  event.stopImmediatePropagation(); // skip remaining listeners on this node too
});

// capture phase listener on the same tree
document.addEventListener('click', (e) => {
  if (e.target.closest('[data-menu]')) e.stopPropagation();
}, { capture: true });
PhaseOrderWhere to use it
CaptureDocument to targetIntercepting before a component sees it
TargetOn the elementOrdinary component handlers
BubbleTarget to documentDelegation, analytics, global shortcuts
// some events do not bubble by default
el.addEventListener('focus', fn);      // does not bubble
el.addEventListener('focusin', fn);    // does bubble - use this for delegation
el.addEventListener('mouseenter', fn); // does not bubble, use mouseover
el.addEventListener('change', fn);     // bubbles (unlike input on some controls)

Delegation for dynamic lists

// one listener for a list whose children come and go
const list = document.querySelector('#todos');

list.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-action]');
  if (!button || !list.contains(button)) return;   // ignore clicks elsewhere

  const item = button.closest('li');
  const id = item.dataset.id;

  switch (button.dataset.action) {
    case 'toggle': item.classList.toggle('done'); break;
    case 'delete': item.remove(); break;
  }
});

// rows added later are handled automatically
list.insertAdjacentHTML('beforeend',
  '<li data-id="3"><span>Write docs</span>' +
  '<button data-action="toggle">Done</button>' +
  '<button data-action="delete">Remove</button></li>');
  1. Attach one listener to a stable ancestor, not one per row.
  2. Use closest() to find the element that carries the meaning, so the handler works whichever descendant was clicked.
  3. Guard with list.contains(node) when the ancestor is high in the tree and clicks from elsewhere also bubble through.
  4. Prefer a data attribute over reading text content - text is for users and changes with translation.
// custom events: a component announcing something
class CartButton extends HTMLElement {
  #emit(name, detail) {
    this.dispatchEvent(new CustomEvent(name, {
      detail,
      bubbles: true,     // let ancestors hear it
      composed: true,    // cross shadow boundaries
    }));
  }
  add(item) { this.#emit('cart:add', { item }); }
}

document.addEventListener('cart:add', (e) => {
  updateBadge(e.detail.item);
});

// listen once, for a one-off
document.addEventListener('app:ready', init, { once: true });

FAQ

Why does my delegated handler fire for clicks outside the list?
The ancestor may be on the path of unrelated clicks - a click on a modal above the list still bubbles to document. Check with event.target.closest(selector) and confirm the node is inside the container before acting.
Why does removeEventListener do nothing?
It requires the same function reference and the same capture flag used when adding. An inline arrow function or a bound copy is a different reference. Store the handler, or use an AbortController and call abort().

Attributes, classes and inline styles from script Web Components: custom elements and shadow DOM

Last refreshed 2026-09-18.