Learning Objectives
- Attach a single delegated listener to a stable parent
- Recover the clicked control with
e.target.closest()andmatches() - Route behavior through
data-actionattributes 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:
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:
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.
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.
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
submitand take over from the browser - React to
inputandchangeas the user types - Read a whole form at once with
FormData