Event Delegation
Handle many elements with one listener on a parent — bubbling, currentTarget vs target, and when not to delegate.
- 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:
- Capture phase: root → target
- Target phase
- 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; usefocusin/focusoutinsteadmouseenter/mouseleave— don’t bubble; usemouseover/mouseoutcarefully (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.
Related on this site
- this Binding Rules — handlers lose
thiswhen detached - The Event Loop — when listeners run
- Focus Management — focusin and a11y patterns
- Accessible Menus — keyboard + delegation
- Debounce Implementation — input events at scale
Further reading
- MDN: Introduction to events
- MDN: Event.closest
- DOM Living Standard — dispatch
- javascript.info: Bubbling and capturing
Related guides
- Custom EventsCustomEvent, detail payloads, bubbles and composed — decoupling components without a global event bus mess.
- Capture, Bubble, once, and passiveEvent propagation phases, addEventListener options — capture, once, passive, and signal — and when each fixes a real bug.
- classList and datasetToggle CSS classes with classList and read data-* attributes via dataset — tokens, naming, and DOM performance notes.
- client, offset, and scroll DimensionsclientWidth vs offsetWidth vs scrollWidth — borders, scrollbars, and which box measurement to use for layout math.
- contentEditable PitfallsWhy contentEditable is hard: browser HTML mess, carets, sanitization, undo, and when to pick a real editor framework.