Event Delegation
debt(d7/e3/b3/t5)
Closest to 'only careful code review or runtime testing' (d7). ESLint can flag some patterns (listeners in loops) and Chrome DevTools can show listener counts, but the core misuses — attaching listeners to every list item, broken handlers on dynamic elements, memory leaks from many individual listeners — typically only surface through runtime profiling or user-reported bugs on large lists. No linter rule catches this by default; it requires specialist inspection.
Closest to 'simple parameterised fix' (e3). The quick_fix states: attach one listener to a common parent and use event.target (or event.target.closest()) to identify the child. This is a small, contained refactor — replace N per-item addEventListener calls with one on a parent — typically within one component or template, not cross-cutting.
Closest to 'localised tax' (b3). The applies_to scope is web contexts only (DOM-based UIs), and the choice is localised to UI components that render lists or dynamic content. Getting it wrong imposes a memory/performance tax on that component, but the rest of the codebase is largely unaffected unless the pattern is systemic across many list components.
Closest to 'notable trap' (t5). The misconception field identifies the trap: developers treat delegation as optional optimisation, but for dynamic lists it is mandatory. The common_mistakes reinforce additional traps: using event.target instead of event.target.closest() causes wrong-target bugs when children are clicked, and delegating too broadly to document/body is a documented gotcha. These are well-known but non-obvious pitfalls that most developers hit at least once.
Also Known As
TL;DR
Explanation
DOM events bubble up from the target element through its ancestors. Event delegation exploits this: instead of attaching listeners to every list item, attach one listener to the list. The listener uses event.target to identify which child was clicked. Benefits: fewer event listeners (memory), works for dynamically added elements, and simplifies code. The pattern is essential for rendering lists where items are added and removed frequently.
Diagram
flowchart TD
subgraph Without_Delegation
LIST[ul with 1000 li items]
LIST -->|1000 event listeners| SLOW[Memory heavy<br/>slow on large lists]
end
subgraph With_Delegation
UL[ul - one listener]
LI1[li item 1]
LI2[li item 2]
LI3[li item 3 - clicked]
LI1 & LI2 & LI3 -->|event bubbles up| UL
UL -->|check e.target| HANDLE[Handle click<br/>for any li]
end
subgraph Dynamic_Items
ADD[Add new li later] --> WORKS[Already handled<br/>no new listener needed]
end
style SLOW fill:#f85149,color:#fff
style UL fill:#238636,color:#fff
style WORKS fill:#238636,color:#fff
Common Misconception
Why It Matters
Common Mistakes
- Not checking event.target matches the expected element — clicks on child elements inside the item bubble up and trigger the handler with the wrong target.
- Using event.target instead of event.target.closest() — clicks on child elements need .closest() to find the intended parent.
- Delegating to document or body for all events — too broad; delegate to the nearest stable ancestor.
- Not stopping propagation when needed — events bubble past the delegation container if not handled.
Code Examples
// 1000 listeners — memory intensive, breaks for dynamic items:
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleClick);
// Added items have no listener
// Removed items hold listener in memory (leak)
});
// Single delegated listener — works for dynamic items:
document.querySelector('.list').addEventListener('click', (e) => {
// Find the closest .item ancestor of the clicked element:
const item = e.target.closest('.item');
if (!item) return; // Click was outside an item
handleClick(item);
});