AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

12 The DOM and the browser / Lesson 2 of 5

Events — propagation, delegation and the event object

13 min · +10 XP · 2 exercises · 5 questions

Read the text13 min
Exercises0/2
Quiz5 q

Quick answer

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.

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>:

  1. Capture: window → document → html → body → ul → li — listeners registered with { capture: true } fire, outermost first.
  2. Target: listeners on button fire (both capture and bubble ones, in registration order).
  3. 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

  • removeEventListener with a fresh arrow (nothing removed).
  • Using target where currentTarget was meant (the click landed on a child).
  • stopPropagation to silence an outer handler; preventDefault on a passive listener.
  • Listening for submit on the button, change when input was needed, keypress at 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 window to the target, target phase, bubble back up; capture: true listens on the way down.
  • "target versus currentTarget?" 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.
  • "preventDefault versus stopPropagation?" Cancel the browser's default action versus stop the event travelling further; independent.
  • "What does { passive: true } do?" Declares the listener will not preventDefault, 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/blur do not (focusin/focusout do).
  • target is where it happened, currentTarget where you listened; preventDefault and stopPropagation are independent.
  • Delegate with one ancestor listener + closest; avoid stopPropagation as coupling.
  • CustomEvent with detail for component communication; submit on the form; input vs change; 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.

Mark as readSign in to keep your place.

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>:

  1. Capture: window → document → html → body → ul → li — listeners registered with { capture: true } fire, outermost first.
  2. Target: listeners on button fire (both capture and bubble ones, in registration order).
  3. 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

  • removeEventListener with a fresh arrow (nothing removed).
  • Using target where currentTarget was meant (the click landed on a child).
  • stopPropagation to silence an outer handler; preventDefault on a passive listener.
  • Listening for submit on the button, change when input was needed, keypress at 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 window to the target, target phase, bubble back up; capture: true listens on the way down.
  • "target versus currentTarget?" 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.
  • "preventDefault versus stopPropagation?" Cancel the browser's default action versus stop the event travelling further; independent.
  • "What does { passive: true } do?" Declares the listener will not preventDefault, 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/blur do not (focusin/focusout do).
  • target is where it happened, currentTarget where you listened; preventDefault and stopPropagation are independent.
  • Delegate with one ancestor listener + closest; avoid stopPropagation as coupling.
  • CustomEvent with detail for component communication; submit on the form; input vs change; 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=true

Delegation 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 · fetch, URLs, CORS and talking to servers →