Delegate events for a list that changes after load
Canonical URL: https://devexamples.com/javascript/delegate-events-for-a-dynamic-list/
The problem
A list gains, loses, and reorders rows after page load, and a listener bound to each row at startup goes stale on every change. The container element itself never leaves the DOM, every row lives inside it, and rows are identified by their markup rather than an index you would have to keep in sync.
Short answer
Attach a single listener to the list container, then walk up from event.target with closest inside the handler; rows added or deleted later are covered by the same listener with no re-binding.
Binding one listener per row means re-binding after every add, remove, or filter — and every row you forget to unhook is a leak against a node that no longer exists. Events travel through the container during the bubble phase, so a single listener on the container can answer for every row, present or future, as long as the handler can work out which row an event came from.
<ul id="task-list">
<li><span>Write launch content</span> <button type="button" class="remove">Remove</button></li>
</ul>const list = document.querySelector('#task-list');
function addRow(text) {
const item = document.createElement('li');
const label = document.createElement('span');
label.textContent = text;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'remove';
remove.textContent = 'Remove';
item.append(label, remove);
list.append(item);
}
// One listener answers for every row, current or future.
list.addEventListener('click', (event) => {
const row = event.target.closest('li');
if (!row || !list.contains(row)) {
return; // click landed on the container itself or outside this list
}
if (event.target.closest('button.remove')) {
row.remove();
return;
}
row.classList.toggle('done');
});
// Rows created after the listener was bound need no wiring of their own.
addRow('Review launch corpus');Explanation
Delegation works because dispatch builds a propagation path from the window down to the clicked node and back up again. A listener on the container sits on that return path for every descendant, and event.target stays the deepest original node — the icon inside the button, the text span inside the row. closest then answers the routing question from that node: it checks the target itself and each ancestor in turn and returns the first match, so the handler recognises the row no matter which piece of row markup was clicked. Because the row is identified at dispatch time rather than at bind time, addRow needs no listener code at all and row.remove() leaves nothing unhooked.
Two choices keep the pattern honest. The listener is bound to the list rather than to the document so unrelated clicks on the rest of the page never enter this handler, and rows are matched by the selector li plus a class-based button test instead of positional indexes, so filtering or reordering cannot shift a click onto the wrong row.
The boundary condition is the container’s own territory. A click that lands in the list’s padding has the container as its target, closest('li') returns null, and the early exit in the guard is what stops the handler from throwing. A second case matters on dense pages: if the list itself sits inside another li — a task list inside a checklist row, for example — closest from a stray click can climb out of the list and match that outer row, and list.contains(row) is what rejects the false positive.
Next steps
Delegation fixes where the listener lives, not how often the work runs. If the delegated handler fires in bursts — fast repeated clicks, or a scroll-driven list — wrap its body in a debounce timer so a flurry collapses into one call.
Common mistakes
- Forgetting the null check after closest: a click on the container's own padding matches no row, and the first property access on null throws inside the handler.
- Re-rendering every row on each state change so listeners can be re-attached; with delegation the rows are plain markup and updating them costs nothing in wiring.
Follow-up
Follow-up
Debounce a scroll handler without dropping the last callOne delegated handler answering for many rows still benefits from a timer when clicks or scrolls arrive in bursts.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Reveal an element the first time it enters the viewportBoth patterns attach one handler or observer so content changes never force you to bind work again.
Same task or topic
Add an event listener with the options you actually needSolves the Handle DOM events task
Same task or topic
Debounce a scroll handler without dropping the last callSolves the Handle DOM events task
References
- Element: closest() method(opens in a new tab) — MDN Web Docs. Matching rules and ancestor walk used to route the delegated event.
- DOM Standard — dispatching events(opens in a new tab) — WHATWG. Normative capture and bubble path a container listener depends on.
Source page: https://devexamples.com/javascript/delegate-events-for-a-dynamic-list/