Event bubbling, capturing, and delegation — what's the difference?
Events flow capture (top → target) → target → bubble (target → top). Delegation puts ONE listener on a parent and uses event.target to handle children. Saves memory and works on dynamically added nodes.
DOM events traverse the tree in three phases: capture (window → parent → ... → target's parent), target (the element you clicked), then bubble (target → parent → ... → window). Most listeners run in the bubble phase. addEventListener(type, handler, true) (or { capture: true }) attaches in the capture phase instead.
Why bubbling matters. A click on <button> inside <li> inside <ul> fires the handler on the button, then the li, then the ul, then up to body and window. Any ancestor can listen and react.
Stop the flow.
event.stopPropagation()— prevents further parents from receiving the event. Use sparingly; it breaks listeners that other code may have legitimately attached upstream (analytics, outside-click detection).event.stopImmediatePropagation()— also stops other handlers on the same element that were registered after this one.event.preventDefault()— different concept: cancels the default browser action (form submit, link navigation), but the event still bubbles.
Event delegation is the practical payoff: instead of attaching one handler per child (<li onClick> × 1000), attach one handler on the parent and read event.target to find which child was clicked. Benefits:
- Memory. One listener vs N listeners — significant for large lists.
- Dynamic content. New children added later automatically work — no need to re-attach listeners.
- Composition. Centralizes the routing logic.
list.addEventListener("click", (e) => {
const li = (e.target as HTMLElement).closest("li[data-id]");
if (!li) return;
const id = li.dataset.id;
handleSelect(id!);
});closest() is the right tool — event.target may be an inner <span> or icon, not the <li> you actually care about.
React's twist. React uses one delegated root listener (since v17, attached to the root container, not document). Synthetic events bubble through the React tree (which can differ from the DOM tree, e.g., portals). e.stopPropagation() in a React handler stops React-tree propagation but does NOT stop the underlying DOM event by default — it does as of React 17+ on the root listener but legacy code may differ. Portals: events still bubble through the React parent, not the DOM parent — useful for modals.
Capture-phase use cases. Rare but real: when you must run before a child's bubble handler can call stopPropagation. Example: a global "outside click closes menu" listener that needs to fire even if the inner content stops propagation.
{ once: true, passive: true } options.
once: auto-removes the listener after the first fire — perfect for one-shot signals.passive: tells the browser the handler will NOT callpreventDefault, so scroll/touch can be smooth without waiting for JS. Use onscrollandtouchmovefor perf — but you can't preventDefault inside.
Common bugs from misunderstanding.
- Calling
stopPropagationin a modal close button → outside-click handler also runs because click on the backdrop still bubbles up to document. Solution: checke.target === e.currentTargeton the backdrop. - Adding handler to a parent that doesn't yet exist (script loaded too early) → use delegation on a stable ancestor like
document.body. - Expecting
mouseenterto bubble → it doesn't (usemouseoverif you need bubbling, but it fires on every descendant).
Code
Follow-up questions
- •Why does mouseenter/mouseleave not bubble?
- •How does React's event delegation work post-v17?
- •When would you actually use a capture-phase listener?
- •What does passive: true buy you?
Common mistakes
- •Using stopPropagation aggressively — breaks unrelated upstream listeners (analytics, outside-click).
- •Confusing preventDefault with stopPropagation — they do different things.
- •Attaching listeners per item instead of delegating — slow for large lists.
- •Reading event.target without closest() — clicking the icon inside the button gives you the icon.
Performance considerations
- •Delegation: one listener vs N — saves memory and setup time.
- •Use { passive: true } for scroll/touchmove to keep the compositor unblocked.
Edge cases
- •focus / blur don't bubble — use focusin / focusout.
- •Custom events default to bubbles: false; pass { bubbles: true } if you need them to bubble.
- •Events fired on a portal'd React node bubble through the React tree, not the DOM parent.
Real-world examples
- •Tables with row actions, dropdown menus, file explorers, command palettes — all use delegation.