Event delegation and bubbling
Events propagate in three phases: capture (root → target), target, bubble (target → root). Delegation = listen on a single ancestor instead of N children; use `event.target.closest(selector)` to identify the actual hit. Cheaper memory, works for dynamic children. `stopPropagation` short-circuits; `preventDefault` is unrelated (cancels the default action).
Phases
When you click a button inside a card inside the body:
- Capture phase — event walks document → html → body → card → button.
- Target phase — event hits the button.
- Bubble phase — event walks button → card → body → html → document.
addEventListener("click", fn) defaults to bubble. Pass { capture: true } to listen on the way down.
Delegation
Instead of attaching a listener to every <li> in a 10,000-item list:
list.addEventListener("click", (e) => {
const li = e.target.closest("li");
if (!li || !list.contains(li)) return;
handleItem(li.dataset.id);
});One listener, works for items added later (dynamic content), tiny memory footprint. The closest() call walks up from the actual click target to find the matching ancestor.
event.target vs event.currentTarget
target— the deepest element the event hit (could be the icon inside the button).currentTarget— the element the listener is bound to (the list in the delegation example).
The icon-inside-button gotcha: if you don't use closest(), your handler sees the icon, not the button.
stopPropagation, stopImmediatePropagation, preventDefault
| Method | Effect |
|---|---|
event.stopPropagation() | Halts bubble/capture to further ancestors. Sibling listeners on the same element still fire. |
event.stopImmediatePropagation() | Halts everything, including other listeners on the same element. |
event.preventDefault() | Cancels the browser's default action (form submit, link navigation). Doesn't stop propagation. |
Use stopPropagation sparingly — it can break ancestor handlers your team doesn't know about.
Non-bubbling events
Some events don't bubble: focus, blur, mouseenter, mouseleave. Their bubbling equivalents: focusin, focusout, mouseover, mouseout. Important for delegation — delegate focus with focusin, not focus.
Passive listeners
addEventListener("touchstart", fn, { passive: true }) tells the browser the handler won't call preventDefault — the browser can scroll without waiting for the handler. Always passive for scroll/touch listeners unless you really need to cancel.
React synthetic events
React uses synthetic events dispatched from a single root listener since v17. onClick looks like a node-level listener but is really delegation under the hood. e.stopPropagation() on a React event stops only React listeners; native listeners on the DOM still fire (and vice versa). This trips people up integrating React with non-React UI.
Interview framing
"Events propagate down (capture) then up (bubble). Delegation puts one listener on a common ancestor and uses event.target.closest(selector) to match — cheaper memory, works for dynamic children. Watch the icon-in-button gotcha: event.target is the deepest hit, event.currentTarget is where you bound. stopPropagation halts further ancestors; preventDefault cancels the default action — different concerns. For scroll/touch use { passive: true }. React's synthetic events delegate to a root, so React handlers don't see events stopped natively and vice versa."
Follow-up questions
- •When is delegation the wrong choice?
- •How do React synthetic events interact with native listeners?
- •What's a passive event listener?
Common mistakes
- •Reading event.target instead of using closest() — picks up nested elements.
- •Calling stopPropagation everywhere — breaks delegation upstream.
- •Trying to delegate focus events without using focusin/focusout.
Performance considerations
- •Delegation reduces memory + setup time on large lists. Passive listeners enable smooth scroll.
Edge cases
- •Disabled buttons still receive bubble in some browsers.
- •Custom elements / shadow DOM — events retarget at the shadow boundary.
- •Composed paths — `event.composedPath()` to see the full route.
Real-world examples
- •Long-list click handling, jQuery's `.on()`, React's root-level event system, dropdown / popover libraries detecting outside clicks.