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);removeEventListenermatches 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
AbortControllerper component is the tidiest cleanup pattern: oneabort()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 });| Phase | Order | Where to use it |
|---|---|---|
| Capture | Document to target | Intercepting before a component sees it |
| Target | On the element | Ordinary component handlers |
| Bubble | Target to document | Delegation, 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>');- Attach one listener to a stable ancestor, not one per row.
- Use
closest()to find the element that carries the meaning, so the handler works whichever descendant was clicked. - Guard with
list.contains(node)when the ancestor is high in the tree and clicks from elsewhere also bubble through. - 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().Related
Attributes, classes and inline styles from script Web Components: custom elements and shadow DOM
Last refreshed 2026-09-18.