ESC

Type to search the knowledge base.

Event Delegation

Handle many elements with one listener on a parent — bubbling, currentTarget vs target, and when not to delegate.

intermediate4 min read
  • javascript
  • dom
  • events
  • delegation

Attaching a click listener to every row in a 500-item list works until the list re-renders, grows, or ships on a low-end phone. Event delegation puts one listener on a common ancestor and uses event bubbling to find out which descendant was the real target.

This is how data tables, chat lists, and keyboard menus stay cheap and correct when items are added or removed.

Specs/docs: MDN Event bubbling, Event.target.

Bubbling in one breath

Most UI events travel:

  1. Capture phase: root → target
  2. Target phase
  3. Bubble phase: target → root

Listeners on ancestors during the bubble phase see the event after the target. Delegation rides that bubble.

document
  └─ ul#list          ← one listener here
       ├─ li > button "A"
       └─ li > button "B"

Click “B” → event targets the button (or text node’s element) → bubbles through li → ul → … Your ul handler still runs.

target vs currentTarget

Property Meaning
event.target The deepest element that was actually clicked (or the event origin)
event.currentTarget The element whose listener is running (the delegated parent)
const list = document.querySelector('#todo-list');

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

  const action = button.dataset.action;
  const id = button.closest('[data-id]')?.dataset.id;

  if (action === 'delete' && id) {
    deleteTodo(id);
  }
  if (action === 'toggle' && id) {
    toggleTodo(id);
  }
});
<ul id="todo-list">
  <li data-id="42">
    Buy milk
    <button type="button" data-action="toggle">Done</button>
    <button type="button" data-action="delete">Delete</button>
  </li>
</ul>

Why closest? Users often click the text node or an icon inside the button. event.target might be <svg> or a <span>, not the element with data-action.

Why list.contains(button)? closest can match an element outside your list if the click somehow involves nested portals/mis-structure — cheap guard.

Dynamic lists without re-binding

function addItem(text) {
  const li = document.createElement('li');
  li.dataset.id = crypto.randomUUID();
  li.innerHTML = `
    <span></span>
    <button type="button" data-action="delete">Delete</button>
  `;
  li.querySelector('span').textContent = text; // avoid XSS via textContent
  list.append(li);
  // no new listeners needed
}

Framework contrast: React attaches listeners at the root for most events (its own delegation system). You still use the same idea in vanilla or when writing non-React widgets.

stopPropagation and the traps

child.addEventListener('click', (e) => {
  e.stopPropagation(); // parent delegated handler never sees this click
});

If a child stops propagation, the parent’s delegated handler won’t run. That’s correct for “this button handles itself,” but it breaks “one parent for everything” if you didn’t expect it.

stopPropagation on document-level analytics listeners is a common production footgun — prefer checking event.defaultPrevented or using capture carefully.

Capture-phase delegation

Rare but useful: listen in capture so you run before target handlers.

parent.addEventListener('click', handler, { capture: true });
// or { capture: true } as third arg true historically

Use when you must intercept (e.g. disable all interactions in a region) regardless of child stopPropagation — note capture still runs before target; order is subtle; know addEventListener options.

What does not bubble (or not well)

Not every event is a good delegation candidate:

  • focus / blur — don’t bubble; use focusin / focusout instead
  • mouseenter / mouseleave — don’t bubble; use mouseover / mouseout carefully (they do bubble but fire a lot)
  • Some media/scroll quirks — verify on MDN for the specific event
form.addEventListener('focusin', (e) => {
  e.target.classList.add('focused-field');
});
form.addEventListener('focusout', (e) => {
  e.target.classList.remove('focused-field');
});

Performance and clarity tradeoffs

Pros: fewer listeners, works for future nodes, one place to log/analytics.
Cons: handler must filter noise (if (!button) return), slightly more complex mental model, easy to mis-handle nested interactive elements.

Don’t delegate everything to document with a giant switch — scope the parent to the widget root.

Interview angle

Q: What is event delegation?
A: One listener on an ancestor relies on bubbling; identify the origin with target/closest; handle actions for many children including those added later.

Q: target vs currentTarget?
A: Origin vs element with the listener.

Demo: live list with delete buttons and a single parent listener. Mention closest and non-bubbling focus events.

Further reading

Related guides