Event Delegation
One listener on a container instead of one per row: fewer registrations, no rebinding after a re-render, and a matching step you now own.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
When a list has a thousand rows and each needs a click handler, where should the listener actually go?
A person acts on one item in a long list — delete this row, expand that section, select this option. They do not care how many other rows exist.
Loop over the rows and attach a click listener to each one. It is direct, it reads well, and the handler already has the row in scope.
Rows arrive later. Anything appended after the loop ran has no listener, so the newest items — the ones the user just created — are the ones that do not respond.
- Rows arrive later. Anything appended after the loop ran has no listener, so the newest items — the ones the user just created — are the ones that do not respond.
- Rows are replaced. A re-render that swaps innerHTML or reconciles new nodes discards the old elements *and* their listeners; the code looks unchanged and the buttons stop working.
- Registration cost scales with the list. Attaching thousands of listeners is thousands of calls plus thousands of retained closures, paid on every render pass rather than once (What a Mutation Costs).
- Cleanup scales too, and is skipped far more often than it is done. Every listener you fail to remove pins its node and everything the closure captured (Memory Leaks).
- Sorting, filtering and virtualisation all reorder or recycle nodes. A per-node listener bound to a row index goes stale the moment the list changes shape (List Virtualization).
What is actually happening
In the browser, not in the framework.
- Delegation is simply using the bubble phase on purpose. One listener on a stable ancestor sees every click that originated in its subtree, because the propagation path always passes through it (How an Event Is Dispatched).
- The listener then does the matching the browser used to do for you: take
event.target, walk up withclosest(selector)to find the row or control, and bail if there is no match. - The container must also verify the match is still its own descendant.
closest()walks past the container if the selector matches an ancestor of it, which produces a handler that fires for elements outside the component. - Because the listener lives on a node that outlives its children, dynamically added, replaced, sorted or recycled descendants need no registration at all. The correctness comes from the DOM structure, not from bookkeeping.
- Only events that bubble can be delegated.
focus,blur,mouseenter,mouseleave,loadand media events do not; their bubbling twins (focusin,focusout) or capture-phase registration are the way through. - Every mainstream framework does this internally. React attaches its listeners at the root container of the tree and dispatches synthetic events from there, which is why a native
stopPropagation()inside that tree can make a React handler never run (The React Mental Model).
What this makes the browser do
And which of it is avoidable.
- One
addEventListenercall and one retained closure per container, instead of one per descendant. On large lists this is the difference between a render pass that allocates thousands of small objects and one that allocates none. - Slightly more work per event: the full path is walked to the container, then
closest()walks part of it back down-to-up in JavaScript. For a click that is nothing; forpointermoveorscrollon a deep tree it is not (The Frame Budget). - Less memory retained overall, and — more importantly — a retention graph that does not grow with how many times the list has re-rendered.
- The avoidable work is delegating high-frequency movement events to a very deep container. Delegate discrete events; attach movement events narrowly, or coalesce them.
One listener, or one per row
The direct version is not wrong on a small static list, and pretending otherwise is how people end up delegating two buttons from document. The argument for delegation gets stronger with exactly two properties: how many descendants there are, and how often they are replaced.
What actually changes is where correctness comes from. In the per-row version, correctness comes from remembering to attach — and to detach — at every point where the list changes. In the delegated version it comes from the DOM structure itself, which is much harder to forget.
function render(items) {
list.innerHTML = items.map(rowHtml).join('')
for (const btn of list.querySelectorAll('.delete')) {
btn.addEventListener('click', () => remove(btn.dataset.id))
}
}
// every render: n registrations, n closures,
// and the previous n listeners silently discarded with their nodeslist.addEventListener('click', (e) => {
const btn = (e.target as Element).closest('[data-action="delete"]')
if (!btn || !list.contains(btn)) return
remove(btn.getAttribute('data-id')!)
})
// registered once, at mount.
// rows added, replaced, sorted or recycled all work unchanged.The delegated version does not need to know when the list changed. Registration cost and retained closures stop scaling with row count, and the class of bug where new rows are dead because the loop already ran cannot occur.
The matching step you now own
Delegation moves one job from the browser to you: deciding which element the event is *about*. Get that job wrong and the handler either misses events or claims events that are not its own. Two lines prevent both — closest() for the match, contains() for the boundary.
The other half is choosing what to match on. Class names change when a designer changes them; tag structure changes when someone adds a wrapper. An explicit data-action attribute is a contract that says "a handler depends on this", which is exactly what you want a future reader to know.
1type Action = (el: HTMLElement, e: Event) => void2 3// A fixed map. Never look up a function by a name that came from the DOM.4const actions: Record<string, Action> = {5 delete: (el) => remove(el.dataset.id!),6 expand: (el) => toggle(el.dataset.id!),7}8 9const ac = new AbortController()10 11list.addEventListener('click', (e) => {12 const el = (e.target as Element).closest<HTMLElement>('[data-action]')13 if (!el) return // the click was not on a control we own14 if (!list.contains(el)) return // closest() can walk PAST the container15 actions[el.dataset.action!]?.(el, e)16}, { signal: ac.signal })17 18// focus does not bubble — focusin does. Same pattern, different event name.19list.addEventListener('focusin', (e) => {20 (e.target as Element).closest('[data-row]')?.classList.add('is-active')21}, { signal: ac.signal })22 23// teardown for the whole component, in one call24export const destroy = () => ac.abort()The two guard lines are the lesson. closest() alone is the single most common delegation bug: a selector that also matches an ancestor turns a component handler into a page-wide one.
When delegation quietly stops working
Every failure below presents as "the button does nothing", with no error in the console. That shared symptom is why it is worth learning the causes as a set: the debugging move is to check them in order rather than to re-read the handler.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
A child component calls stopPropagation() on click | The delegated handler stops firing for that subtree only | The event never reaches the container; the path was cut below it | Find it in the Event Listeners pane and delete it, or scope the child's concern properly (preventDefault vs stopPropagation). |
Delegating focus or mouseenter | Handler never runs at all, for any descendant | Those event types do not bubble, so the container is never on the path | Use focusin/focusout, or register with capture: true. |
| The matched class was renamed | Silent no-op after an unrelated styling change | The selector is a structural dependency nobody knew existed | Match on data-action, and treat it as API surface in reviews. |
Missing contains() check | Component handler fires for clicks elsewhere on the page | closest() walked above the container to a matching ancestor | Add the containment guard; it is one line and it is not optional. |
Control is disabled | No event, no feedback, user assumes the app is broken | Engines do not dispatch click from a disabled form control | Prefer aria-disabled plus a handled no-op when you owe the user an explanation (The Rules of ARIA). |
| The event came from inside a shadow root | event.target is the host, so the selector never matches internals | Retargeting hides the shadow tree from outside listeners | Use composedPath()[0], or handle the event inside the component (Shadow DOM and the Composed Tree). |
How to build it
Most important first.
- Delegate on the nearest stable ancestor, not on
document. The component root is stable enough, keeps the handler scoped to the component, and avoids competing with every other global listener in the app. - Match with
data-*attributes andclosest('[data-action]')rather than with class names or tag structure. Classes are styling and change for visual reasons; a data attribute is an explicit contract with the handler. - Always re-check containment:
const el = e.target.closest(sel); if (!el || !container.contains(el)) return. Without it, a selector that also matches an ancestor makes the handler fire for the whole page. - Delegate
focusin/focusoutinstead offocus/blur, and use capture-phase registration for the rest of the non-bubbling set. - Keep the delegated handler a dispatcher: find the element, read the action, call a function. Handlers that grow into a switch statement with the whole feature inside are how delegation gets a bad reputation.
- Do not delegate a form's submit handling to a click listener on the button. Submit is its own event with its own default action and its own keyboard paths (Submission: Method, Encoding and Doing It Once).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Delegation is orthogonal to semantics, and that is exactly the trap. A delegated handler on a container makes plain
<div>rows appear to work with a mouse while remaining unreachable by keyboard and unannounced to assistive technology (Div Soup: How It Happens and What It Costs). - The delegated target must still be a real interactive element: a
<button>inside the row, or an anchor if it navigates. Then the browser supplies focusability, the role, the name and the Enter/Space handling for free (Semantics Are Behaviour). - Keyboard activation produces a trusted
clickthat bubbles like any other, so a correctly built delegated handler works for keyboard and screen-reader users without a single extra line (Keyboard Events). - Roving-tabindex patterns — a grid, a toolbar, a listbox — pair naturally with delegation: one
keydownlistener on the container manages arrow-key movement and focus, matching the same way clicks are matched (Accessible Component Patterns). - When the delegated action removes the row that had focus, focus falls to
<body>and the screen reader loses its place. Move focus deliberately to the next row or a status message (Focus Management).
What can go wrong
- A descendant component calling
stopPropagation()for its own local reasons. The delegated handler above it never fires, there is no error, and the two pieces of code were written months apart by different people (preventDefault vs stopPropagation). - Delegating from
documentin an app that also delegates fromdocumentelsewhere. Ordering between the two is registration order, which is module load order, which changes when a chunk is split differently (Code Splitting). - Matching by class, then a redesign renames the class. The handler stops matching and reports nothing, because "no match, return early" is indistinguishable from "nothing was clicked".
- Forgetting that clicks on a
disabledcontrol do not dispatch at all in most engines — so a delegated handler that expects to see and ignore them never gets the chance to give feedback. - The mitigation failing: delegation removes the leak from per-row listeners, then the container listener itself is added on every mount and never removed. One leak replaced by a slower one.
- The clicked row can be removed from the DOM before an async delegated handler resumes. After an
await,closest()returns a detached node whose measurements are all zero (The Microtask Checkpoint). - A rapid double activation dispatches two events before the first handler's state update has rendered, so both read the same stale row state (State Synchronization).
- During a reconciliation, the node under the pointer may be replaced between
pointerdownandclick, so the delegatedclickmatches a different row than the one the user pressed (Reconciliation and Keys).
- A delegated listener sees every event from its whole subtree, including from markup you did not author. If any part of that subtree renders user-supplied HTML, your handler is reading attacker-controlled
data-*values (Cross-Site Scripting). - Treat matched attributes as untrusted input: an action name from
data-actionshould index a fixed map of known handlers, never be used to look up a function by name on an object you also expose. - Delegation does not weaken origin isolation — an event from a cross-origin
<iframe>inside your container never reaches you (The Same-Origin Policy). - A capture-phase delegated listener on
documentis an excellent keylogger, which is a reason to care what third-party script you allow to run (Third-Party Scripts and the Supply Chain).
- "Delegation is a performance optimisation." Its main benefit is correctness over time: dynamic, replaced and recycled nodes work without any registration bookkeeping. The memory win is real but secondary.
- "Delegate everything from
document." That maximises path length, maximises collisions with other global listeners, and makes ordering depend on module load order. The nearest stable ancestor is almost always better. - "Delegation replaces semantic markup." It replaces listener registration. The row still needs a real button or link, or it is only operable by mouse (Keyboard Operability).
- "
closest()is enough."closest()will happily walk past your container. Without a containment check, one selector match makes your component handle clicks from the entire page. - "Frameworks removed the need for this." Frameworks *implement* this. Knowing it is what explains why a stray
stopPropagation()breaks a handler several components away (The React Mental Model).
Measuring it, and what changes in the field
- The Elements panel's Event Listeners pane, with "Ancestors" enabled, shows exactly which delegated listener is going to see a given node's clicks.
- A heap snapshot comparison across repeated list re-renders: per-row listeners show up as a listener count that climbs with each pass, delegation as one that does not (Debugging Memory).
- Break on the delegated handler and step: if
closest()returnsnull, the selector or the DOM changed; if it returns something outside the container, the containment check is missing (A Method for Frontend Bugs). - In the Performance panel, a delegated handler on a high-frequency event shows as many short handler entries rather than one long one — easy to miss unless you look at the aggregate (A Mental Model of the Devtools).
- On a large list, delegation is unambiguously better: registration cost and retained memory stop scaling with row count entirely.
- On a handful of static controls it is over-engineering. Two buttons that will always be there are two listeners, and the direct version is easier to read.
- On a slow device, the saved registration work matters most during the render pass — the exact moment the main thread is already the bottleneck (Long Tasks).
- In a virtualised list, delegation is close to mandatory: rows are recycled constantly, and per-row listeners would be attached and detached on every scroll frame (List Virtualization).
- You take over matching from the browser.
closest()and a data attribute are a contract you now maintain, and a rename in the markup can break it silently — a class of bug per-node listeners simply do not have. - Handlers become slightly further from the thing they act on, which costs readability. A well-named action attribute buys most of it back.
- A shared listener is a shared failure point: an exception in one branch aborts the handler for every action it serves, so the dispatcher needs to be boring and total.
- You inherit a dependency on bubbling, which makes you vulnerable to anyone below you calling
stopPropagation()— including third-party widgets you cannot edit.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALBubbling,
closest()and the non-bubbling event set are DOM specification behaviour and identical across engines; what varies is only which node a given framework chooses as its delegation root. - FRAMEWORK-SPECIFICReact delegates from the root container it renders into, so native listeners between that root and the element decide ordering; Vue, Svelte, Solid and Angular attach listeners to the actual element, which means the same stopPropagation call has a very different blast radius depending on the framework.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.