JavaScript Event Bubbling, Capturing and Event Delegation
DOM events travel down in the capture phase and back up in the bubble phase. Event delegation, target vs currentTarget, preventDefault and custom events.
- Course: JavaScript study plan
- Module: The DOM and the browser
- Kind: Lesson
- Reading time: 13 min
- Runtime: Node 16
What is event bubbling in JavaScript?
Event bubbling is the phase in which a DOM event, after reaching the element it happened on, travels back up through each ancestor to document and window, firing ordinary listeners innermost first. It follows the capture phase, which travels down from window to the target. Most events bubble; focus, blur, mouseenter and mouseleave do not.
Lesson
Everything a user does reaches your code as an event dispatched on a DOM node and travelling through the tree — down from the document to the target (capture), then back up (bubble). Understanding that path explains delegation (one listener on a parent handling thousands of children), why stopPropagation is usually the wrong fix, what target and currentTarget mean, and how a framework's synthetic event system works. This lesson covers the listener API, the three phases, the event object and its defaults, delegation, custom events, and the listener-lifecycle mistakes that leak memory.
Listening
button.addEventListener("click", onClick); // any number of listeners per event
button.addEventListener("click", onClick, { once: true }); // auto-removed after the first call
window.addEventListener("scroll", onScroll, { passive: true }); // promise never to preventDefault → smoother scrolling
button.removeEventListener("click", onClick); // needs the SAME function reference — arrows inline cannot be removed
const ac = new AbortController();
el.addEventListener("input", onInput, { signal: ac.signal }); // ac.abort() removes every listener registered with the signal
el.onclick = fn; // the property form — one handler only; avoid
Listeners run in registration order, synchronously, before dispatchEvent returns. An exception in one does not stop the others (it is reported as an uncaught error). this inside a non-arrow listener is currentTarget.
The three phases
Dispatching click on a <button> inside <li> inside <ul> inside <body>:
- Capture:
window → document → html → body → ul → li— listeners registered with{ capture: true }fire, outermost first. - Target: listeners on
buttonfire (both capture and bubble ones, in registration order). - Bubble:
li → ul → body → html → document → window— ordinary listeners fire, innermost first.
Most events bubble (click, input, keydown, submit); a few do not (focus/blur — use focusin/focusout; load, scroll on elements, mouseenter/mouseleave). event.eventPhase says which phase is running; event.composedPath() lists the route (including shadow DOM boundaries).
The event object
el.addEventListener("click", (e) => {
e.type; // "click"
e.target; // the element the event happened ON (deepest) — a <span> inside the button, perhaps
e.currentTarget; // the element this listener is attached to — what you usually want
e.preventDefault(); // cancel the browser's default action: following a link, submitting a form, typing a character, checkbox toggle
e.stopPropagation(); // no further nodes in the path receive it (listeners on THIS node still run)
e.stopImmediatePropagation(); // not even the remaining listeners on this node
e.defaultPrevented; e.bubbles; e.cancelable; e.timeStamp; e.isTrusted; // isTrusted: false for synthetic dispatchEvent
// per-type fields: e.key / e.code (keyboard), e.clientX / e.button (pointer), e.data (input), e.deltaY (wheel)
});
preventDefault and stopPropagation are independent: cancelling a form's submit does not stop the event bubbling, and stopping the bubble does not cancel the submit. Passive listeners cannot preventDefault (the call is ignored with a warning) — that promise is what lets the browser scroll before your handler runs.
Delegation
list.addEventListener("click", (e) => {
const item = e.target.closest("[data-id]"); // walk up from the deepest element to the item container
if (!item || !list.contains(item)) return; // clicked between items, or outside
const action = e.target.closest("[data-action]")?.dataset.action;
if (action === "remove") remove(item.dataset.id);
});
One listener on the container handles every current and future child: no per-item wiring, no leaks when items are removed, one function to maintain. e.target is wherever the click landed (an icon inside a button inside the item), so closest climbs to the element you care about. Every framework's event system is delegation at the root plus a lookup table.
stopPropagation — mostly a smell
Stopping propagation to keep an outer handler from running couples the two handlers invisibly: the outer one (analytics, a "click outside to close" handler, a delegated listener) silently never sees the event. Prefer letting events bubble and having the outer handler check e.target (if (dialog.contains(e.target)) return). Legitimate uses are rare: nested interactive components with genuinely conflicting semantics.
Custom events
const ev = new CustomEvent("cart:add", { detail: { id, qty }, bubbles: true, cancelable: true });
const notCancelled = item.dispatchEvent(ev); // false if a listener called preventDefault
document.addEventListener("cart:add", (e) => e.detail.id);
Custom events decouple components: the item announces, anyone listens, nobody imports anybody. bubbles: true if a distant ancestor should hear it; detail carries the payload. In Node the same shape is EventEmitter/EventTarget (module 9).
Input, change, keyboard, pointer
input fires on every keystroke/paste (value changing); change on commit (blur for text, immediately for checkboxes/selects). keydown (repeats while held, cancelable — the one for shortcuts; use e.key like "Enter"/"a", e.code for physical position) versus keyup; keypress is deprecated. Pointer events (pointerdown/move/up) unify mouse, touch and pen; click still fires for taps and for keyboard activation of buttons (accessibility for free — do not replace buttons with divs). submit fires on the form, not the button; handle it there and call e.preventDefault() for JavaScript submission.
Lifecycle and leaks
A listener keeps its closure alive, and the closure keeps whatever it references. Adding a listener on window/document from a component and never removing it leaks the component; adding a listener per render leaks n copies. Remove in teardown (frameworks' unmount hooks), or use { signal } with one AbortController per component, or delegate to a parent that lives as long as the children. Listeners on a removed element go away with it — if nothing else references the element.
Common mistakes
removeEventListenerwith a fresh arrow (nothing removed).- Using
targetwherecurrentTargetwas meant (the click landed on a child). stopPropagationto silence an outer handler;preventDefaulton a passive listener.- Listening for
submiton the button,changewheninputwas needed,keypressat all. - Adding listeners in a loop over items instead of delegating; forgetting teardown.
- Replacing
<button>with a<div onclick>and losing keyboard/AT accessibility.
Interview angle
- "Explain event propagation." Capture down from
windowto the target, target phase, bubble back up;capture: truelistens on the way down. - "
targetversuscurrentTarget?" Where the event originated versus where the listener is attached. - "What is event delegation and why use it?" One listener on an ancestor handling descendants via
closest; fewer listeners, works for future children, no per-item cleanup. - "
preventDefaultversusstopPropagation?" Cancel the browser's default action versus stop the event travelling further; independent. - "What does
{ passive: true }do?" Declares the listener will notpreventDefault, letting the browser scroll immediately.
Key takeaways
addEventListener(type, fn, { once, passive, capture, signal }); remove with the same reference or a signal; listeners run synchronously in order.- Capture (down) → target → bubble (up); most events bubble;
focus/blurdo not (focusin/focusoutdo). targetis where it happened,currentTargetwhere you listened;preventDefaultandstopPropagationare independent.- Delegate with one ancestor listener +
closest; avoidstopPropagationas coupling. CustomEventwithdetailfor component communication;submiton the form;inputvschange;keydown+e.key.
Common questions
What is event delegation in JavaScript?
Event delegation is handling events for many children with one listener on a common ancestor. Because events bubble, a click on any item reaches the container, where e.target.closest("[data-id]") finds the item that was clicked. It needs no per-item wiring, works for items added later, and leaves nothing to clean up when items are removed.
What is the difference between target and currentTarget?
e.target is the element the event happened on, the deepest one, perhaps an icon inside a button. e.currentTarget is the element whose listener is running, which is usually what a handler wants. The two differ whenever the event bubbled up from a descendant.
What is the difference between preventDefault and stopPropagation?
preventDefault cancels the browser's default action, such as following a link, submitting a form or toggling a checkbox. stopPropagation stops the event travelling to further elements in its path. They are independent: cancelling a form's submit does not stop the event bubbling, and stopping the bubble does not cancel the submit.
Why does removeEventListener not work?
Usually because it was given a different function from the one addEventListener received: an inline arrow creates a new function each time, so nothing matches and nothing is removed. Keep a reference to the handler, or register listeners with an AbortController's signal and call abort() to remove them all at once.
What does passive: true do on an event listener?
It promises the browser that the listener will never call preventDefault, so the browser can scroll immediately instead of waiting for the handler to finish. A preventDefault call inside a passive listener is ignored with a console warning. Use it on scroll, wheel and touch listeners that only observe.
Exercises
A three-phase event dispatcher
Lines containing > declare node paths (document > body > ul#list > li#a > button#b; a node's name is its id, or its tag when it has none). on <node> <type> <capture|bubble> <label> [stop|stopImmediate|prevent] registers a listener. dispatch <node> <type> runs the three phases: capture from the root down to the target's parent (capture listeners only), target (capture-registered listeners first, then bubble-registered — the modern spec), bubble from the parent up to the root (bubble listeners). Each invoked listener prints <label> @<currentTarget> target=<target> phase=<capture|target|bubble>; stop stops further nodes, stopImmediate also the remaining listeners on the same node, prevent sets the flag. After dispatch print dispatch <type> on <node>: defaultPrevented=<bool>.
Example (excerpt): listeners on document (capture), list (bubble and capture), a (bubble), b (bubble with prevent, capture) →
doc-capture @document target=b phase=capture
list-capture @list target=b phase=capture
b-capture @b target=b phase=target
b-bubble @b target=b phase=target
a-bubble @a target=b phase=bubble
list-bubble @list target=b phase=bubble
dispatch click on b: defaultPrevented=trueDelegation with closest
A container holds items (add <id> [action], remove <id>). Register one delegated click listener on the container and one once listener that prints first click on the container!. A click arrives as a target path such as item3.label (a child inside item 3), item2, or gap (between items); the delegated listener resolves the item the way e.target.closest("[data-id]") would (the first segment), prints click <path>: <action or select> on <id> or click <path>: ignored (no item), and counts handled actions. Finish with listenersAdded=<n> active=<n> items=<n> handled=<JSON sorted by action>.
Example: add item1 open, add item2, click item1.icon, click item2, click gap, add item3 delete, click item3.label, remove item1, click item1 →
click item1.icon: open on item1
first click on the container!
click item2: select on item2
click gap: ignored (no item)
click item3.label: delete on item3
click item1: ignored (no item)
listenersAdded=2 active=1 items=2 handled={"delete":1,"open":1,"select":1}
Items added after the listener was registered still work; the removed one is simply not found.
In this module: The DOM and the browser
- The DOM — the tree behind the page, and how to change it safely
- Events — propagation, delegation and the event object (this lesson)
- fetch, URLs, CORS and talking to servers
- Storage, cookies, history and the browser APIs worth knowing by name
- Rendering performance and front-end security — the two things a page must not get wrong
- Checkpoint — The DOM and the browser
← The DOM — the tree behind the page, and how to change it safely · fetch, URLs, CORS and talking to servers →