Event Delegation
Learn the event delegation pattern — attaching one listener to a common ancestor instead of many listeners to individual children — and when bubbling makes it work and when it does not.
Reading Time
16 min
Lesson
Lesson 35 of 48
A Quick Recap of Bubbling
The previous lesson introduced event bubbling: when an event fires on an element, it doesn't just fire there — it travels back up through every ancestor of that element, all the way to the document. A click on a <button> inside a <li> inside a <ul> triggers click listeners on the button, then the <li>, then the <ul>, and so on upward. Event delegation is the pattern that puts this behavior to deliberate use, rather than treating it as a side effect to be aware of.
The Delegation Pattern
Instead of attaching a separate listener to every individual child element, event delegation attaches a single listener to a shared ancestor, and lets bubbling carry every descendant's events up to it. Inside that one listener, you inspect event.target to figure out which specific child the event actually originated from, and respond accordingly. Because the browser walks the DOM for you via bubbling, one listener on a parent can effectively handle events for dozens, hundreds, or even elements that don't exist yet at the time the listener was attached.
Without Delegation: One Listener Per Child
Console Output
Click “Run” to see the console output here.
With Delegation: One Listener on the Parent
Console Output
Click “Run” to see the console output here.
Why closest() Matters
event.target is the exact element the user interacted with, which might be a nested element inside the one you actually care about — a click on the text inside an <li>, or on an icon inside a button. element.closest(selector) walks up from that element (including itself) and returns the nearest ancestor matching the given CSS selector, or null if none matches. This makes it reliable for finding "which <li> was this click inside of", regardless of how deeply nested the actual clicked element was.
Why Delegation is Useful: Dynamic Elements
Listeners attached directly to specific elements only work for elements that already existed in the DOM at the moment addEventListener() ran. If new elements are added later — items appended after an API call, rows inserted after a user action — those new elements have no listener of their own, and clicking them does nothing unless you remember to re-attach a listener every time. Delegation sidesteps this entirely: since the listener lives on the ancestor and the ancestor doesn't change, every new descendant is automatically covered, with zero extra setup.
Why Delegation is Useful: Performance
Every listener the browser attaches costs a small amount of memory and setup work. For a handful of elements this is irrelevant, but for a list with hundreds or thousands of rows — a data table, a chat log, a product grid — attaching a listener to every single row can measurably add up. One listener on the shared container achieves the same behavior with a single attachment, regardless of how many children exist or how often they change.
Delegation vs. Direct Listeners
| Aspect | Direct Listener per Child | Delegated Listener on Parent |
|---|---|---|
| Setup cost | One listener per element | A single listener, regardless of child count |
| New children added later | Need a new listener attached manually | Automatically covered — no extra work |
| Memory usage | Grows with the number of elements | Constant, no matter how many children exist |
| Code to find the source element | Not needed — "this" is already the element | Needed — use event.target.closest() |
When NOT to Use Delegation
Delegation depends entirely on bubbling, so it doesn't work for events that don't bubble. focus and blur are the classic example — they fire only on the exact element that gained or lost focus and never travel upward, so a listener on an ancestor container will simply never see them. (The related focusin and focusout events do bubble and can be delegated normally.) The workaround, if you specifically need focus or blur with a single ancestor listener, is to attach the listener during the capturing phase instead of the default bubbling phase, since capturing does reach every descendant on the way down — though at that point it's often simpler to just attach direct listeners to the few elements involved.
focus Does Not Bubble — the Capturing Workaround
Console Output
Click “Run” to see the console output here.
When Delegation Makes Sense
- A list, table, or grid where children are added or removed dynamically
- A large number of similar interactive children (rows, cards, list items)
- Handling clicks on multiple kinds of nested controls within each child (e.g. a delete button and a select button)
- Reducing the number of listeners attached for memory or setup-cost reasons
Delegation Doesn't Work for Non-Bubbling Events
focus, blur, and a handful of other events fire only on their exact target and never bubble. Attaching a delegated listener for these on an ancestor will silently do nothing — no error, the callback simply never runs. Use focusin/focusout (which do bubble), or attach the listener with the capturing option, or fall back to direct listeners on those specific elements.
Always Guard for a Missed Match
Inside a delegated listener, event.target might be the container itself, or an element that doesn't match what you're looking for at all — a click on empty padding inside a <ul>, for instance. Always check that closest() actually returned an element (rather than null) before acting on it, exactly like the `if (!item) return;` guard in the todo-list example.