Step 04

Event delegation

Here is the problem the last step set up: a list where items come and go. Give every item's buttons their own listeners and you are signing up for bookkeeping — attach on create, detach on remove, and re-attach for anything added later. Miss one and you have a dead button or a leak. Delegation dissolves the whole problem: put one listener on the container, and let bubbling deliver every child's events to it — including children that do not exist yet.

Learning Objectives

  • Attach a single delegated listener to a stable parent
  • Recover the clicked control with e.target.closest() and matches()
  • Route behavior through data-action attributes instead of per-button handlers
  • Explain why delegation wins on memory and correctness for dynamic content

The pattern

Three moves. Listen on the parent; find out which control the click came from; act on its data-action. This is the entire click-handling code for the demo's list — add, done, and remove, for any number of items, present or future:

// One listener handles all buttons now and in the future list.addEventListener('click', (e) => { const btn = e.target.closest('button'); if (!btn) return; // click wasn't on a button const li = btn.closest('.item'); const action = btn.dataset.action; if (action === 'done') { li.classList.toggle('done'); } if (action === 'remove') { li.remove(); } });

This is where step 2's distinction earns its keep: e.target is the button actually clicked, while the listener runs on the list — currentTarget. closest() bridges the gap, walking up from the target to the nearest matching ancestor, and returning null when the click landed on none — which is the early-return guard. The buttons themselves carry no behavior at all, just a label:

<button type="button" data-action="done" class="btn-primary">Done</button> <button type="button" data-action="remove">Remove</button>

Live demo

Left: the delegated list. Add items, mark them done, remove them — then look at the source and confirm there is exactly one click listener for the whole list. Right: the cost argument made concrete. Add 500 buttons creates five hundred live controls; the listener count stays at one.

// Performance demo: still just one listener on #container container.addEventListener('click', (e) => { if (e.target.matches('button')) perfOut.textContent = 'Clicked ' + e.target.textContent; });
matches() or closest()? matches() tests the target itself — enough when the clickable thing has no clickable children. closest() also catches clicks on things inside the control (an icon or span in a button), which is why it is the safer default.

Why this is the professional pattern

Per-child listeners cost memory in proportion to your content, and — worse — they couple correctness to your bookkeeping: every code path that creates an item must remember to wire it. Delegation costs one listener regardless of size, and new children are handled by construction, not by discipline. It is also not a niche trick: frameworks' event systems (React's synthetic events among them) have historically been delegation under the hood — listeners at the root, events routed to components by target. When you write it by hand you are writing the same machinery, minus the framework.

The one prerequisite is bubbling. Delegation only hears events that reach the container — a stopPropagation() call in a descendant silences it, and a few event types (focus, blur) do not bubble at all. For those, use their bubbling variants (focusin, focusout) or a capture-phase listener.

Next Steps

In Step 5: Forms: events that do work, events stop being demonstrations and start earning a living:

  • Intercept submit and take over from the browser
  • React to input and change as the user types
  • Read a whole form at once with FormData