A list of tasks has a Remove button on every row. Clicking the button works, but clicking the icon inside it does nothing. A new task added with JavaScript might also behave differently from one that was in the original HTML.
Those are useful cases for event delegation: attach a listener to a stable ancestor, then identify the relevant descendant from the event that reaches it. Delegation can simplify dynamic interfaces, but the naive version has a subtle bug.
list.addEventListener('click', (event) => {
if (event.target.matches('button')) {
// This misses a click whose target is the button's child <span>.
}
});
The reliable version needs both an ancestor search and a boundary check. The example below also shows what delegation does not solve.
The mechanism: the event reaches the ancestor
For a normal bubbling click, the event is dispatched at a target and then travels through its ancestors during the bubbling phase. A listener registered on the list can therefore respond to clicks on buttons inserted after that listener was registered.
There are two different references inside the handler:
event.targetis the event's target as exposed to this listener. In ordinary light DOM, clicking the icon's<span>makes that span the target.event.currentTargetis the element whose listener is currently running: the list in this example.
Delegation does not mean the browser reassigns target to the matching button. The application must find the button. The MDN event bubbling guide explains the dispatch phases and the distinction between target and currentTarget.
A complete dynamic task-list example
This HTML uses actual buttons rather than clickable <li> elements. That preserves built-in button keyboard activation and semantics without an extra keyboard-event implementation.
<ul id="tasks">
<li data-task-id="1">
<span>Read the spec</span>
<button type="button" data-action="remove" aria-label="Remove Read the spec">
<span aria-hidden="true">×</span>
</button>
</li>
</ul>
<button id="add-task" type="button">Add task</button>
Run this JavaScript after the markup exists, for example in a <script> placed after it:
const tasks = document.querySelector('#tasks');
const addTask = document.querySelector('#add-task');
let nextTaskId = 1;
// One listener handles every matching button inside the list.
tasks.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-action="remove"]');
if (!button || !tasks.contains(button)) return;
const task = button.closest('li[data-task-id]');
// The Remove button must be directly inside the row it controls.
if (!task || task.parentElement !== tasks || button.parentElement !== task) {
return;
}
const wasFocused = task.contains(document.activeElement);
const rowButton = (row) =>
row?.matches('li[data-task-id]')
? row.querySelector(':scope > button[data-action="remove"]')
: null;
const nextFocus =
rowButton(task.nextElementSibling) ??
rowButton(task.previousElementSibling) ??
addTask;
task.remove();
if (wasFocused) nextFocus.focus();
});
addTask.addEventListener('click', () => {
const label = `Task ${++nextTaskId}`;
const item = document.createElement('li');
item.dataset.taskId = String(nextTaskId);
const text = document.createElement('span');
text.textContent = label;
const button = document.createElement('button');
button.type = 'button';
button.dataset.action = 'remove';
button.setAttribute('aria-label', `Remove ${label}`);
const icon = document.createElement('span');
icon.setAttribute('aria-hidden', 'true');
icon.textContent = '×';
button.append(icon);
item.append(text, button);
tasks.append(item);
});
Try removing the original task by clicking either the button or the × icon. Then add a task and remove that one. The dynamically created button works without another listener because its click bubbles to #tasks. When the removed row contained the focused control, focus moves to a neighboring row's Remove button or Add task if the list is empty.
This example deliberately builds the new nodes with createElement() and textContent. An incoming task label should not be interpolated into innerHTML unless the HTML is trusted or appropriately sanitized.
Why closest() and the boundary check both matter
Element.closest(selector) checks the starting element, then walks upward until it finds a match or reaches the root. If the click lands on the inner icon, closest('button[data-action="remove"]') finds its button. By contrast, matches() examines only the actual target. The instanceof Element guard avoids assuming every EventTarget exposes DOM element methods. See MDN's closest() reference.
But closest() is not scoped to the listener's ancestor. It can walk past that ancestor in other DOM arrangements. tasks.contains(button) makes sure the candidate belongs to this list. The next checks establish the example's DOM contract: the task must be a direct child of the list, and its Remove button must be a direct child of that row. Otherwise, a matching button in a nested list could delete the outer task. If your UI wraps its controls in extra markup, adapt the ownership check to that structure.
Focus restoration is separate from delegation. Removing a focused control without moving focus can leave keyboard users at the document body. W3C's keyboard-interface guidance recommends keeping focus in a logical, visible place after deleting an item. The destination depends on the UI; a more complex list may also announce the change.
The selector also limits the action to a known data-action value. Don't turn arbitrary values from data-action into function names or HTML. For multiple actions, use an explicit set of permitted actions and handle each one deliberately.
Where a delegated listener can fail
A delegated listener only runs if the event actually reaches it. That is a propagation rule, not a limitation of closest().
| Symptom | Likely cause | What to check |
|---|---|---|
| Clicking a button works but clicking its icon fails | Handler uses matches() on the inner target | Use closest() and verify containment |
| New row is visible but has no response | Listener was attached to the original child nodes or a replaced ancestor | Attach to a stable ancestor that still exists |
| A child responds but the list listener never runs | A descendant calls stopPropagation() or the event does not bubble | Inspect propagation, event type, and other handlers |
| Unrelated click triggers an action | Selector is too broad or a nested action passes a loose row check | Check data-action, containment, and the action button's actual row ownership |
| Click inside a web component finds the host, not its internal button | Shadow DOM retargets the event for an outside listener | Treat the component as a boundary; use an exposed component event |
Stopped propagation and events that do not bubble
A descendant calling event.stopPropagation() prevents the event from continuing to ancestors on its propagation path. preventDefault() does something different: it cancels an event's default action when allowed, but does not by itself stop propagation. Avoid using either as a reflexive fix for delegation. See MDN on stopPropagation().
Not every event behaves like click. For example, focus does not bubble, while focusin does. A parent can alternatively observe focus during the capture phase, but that is a different registration choice. Similarly, mouseenter does not bubble, whereas mouseover does and fires during transitions among nested descendants. See MDN's focusin reference and mouseenter reference.
If a handler never fires, first inspect event.bubbles, where the listener is registered, and whether any code stops the event. Adding more listeners without understanding the event path usually hides the cause.
Shadow DOM changes what outside listeners can see
Delegation across a web component boundary needs special care. For an ordinary composed click dispatched inside a shadow root, an external handler generally observes the host element as event.target, not the internal button. For an open shadow root, event.composedPath() can reveal nodes in the event path; a closed shadow root hides its internal nodes from outside listeners.
This is a reason not to make application code depend on a component's private internal markup. Prefer a documented component-level event when the component needs to report an action. A custom event intended to reach an outside ancestor across the boundary generally needs both bubbles: true and composed: true. Synthetic events without those options do not automatically behave like native clicks. Refer to MDN's composedPath() documentation and the composed property. The Shadow DOM article covers that boundary in more depth.
When delegation is preferable, and when it isn't
Delegation is a good fit when numerous similar descendants trigger the same action, or when matching descendants are inserted and removed frequently. It means the application does not have to register a callback on every newly created button.
That is not proof of a measurable speed or memory improvement. Fewer registrations can reduce setup work, but every event that reaches the shared ancestor may require selector checks. For a small, static set of controls, direct listeners can be simpler. For high-frequency events, an expensive ancestor handler may do more work than a targeted one. Profile the specific interface before making performance claims.
Keep the delegated listener on the nearest stable container that owns the interaction. A global document handler is not automatically better: it sees events outside the component's responsibility and makes action boundaries harder to reason about.
Delegated listeners still need cleanup
If the list component itself is mounted and unmounted, its listener has a lifecycle. Delegation removes the need to attach a listener to each child; it does not remove the need to stop listening when the owning component is disposed.
For an explicitly managed component, an AbortController can handle removal:
const controller = new AbortController();
function onTaskClick(event) {
// Resolve and handle the permitted task action.
}
tasks.addEventListener('click', onTaskClick, {
signal: controller.signal,
});
// When the owning component is disposed:
controller.abort();
This is a standalone cleanup illustration, not additional code to paste after the earlier live example. The signal option and matching removeEventListener() behavior are documented in MDN's EventTarget APIs. Repeated setup with new callbacks can otherwise produce multiple active listeners, as explained in the closures and callback lifecycle article.
A short debugging sequence
If delegated interaction breaks, inspect it in this order:
- Confirm that the listener is attached to a container that still exists after a render or DOM replacement.
- Log
event.target,event.currentTarget, andevent.bubblesinside the handler.currentTargetis only meaningful during dispatch. - Check
closest()and the container boundary separately. Clicking nested icons should behave like clicking their parent buttons. - Look for a non-bubbling event,
stopPropagation(), or a shadow boundary. - Check repeated registration and disposal. Count active callbacks rather than assuming a single source line creates a single listener.
- Test mouse, keyboard, nested controls, and newly inserted rows. Verify focus stays useful after deletion. Native semantic controls are a starting point, not a replacement for focus management.
The pattern is simple: listen at the stable owner, resolve the intended control, and verify that the control belongs to the owner's scope. Whether it is appropriate still depends on event propagation, component boundaries and lifecycle, not on an assumed performance win.
