Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~21 min· topic 2 of 8

Topic 9.2

Bubbling and Capturing

In one line

An event doesn't only happen on the element you clicked: it travels down the DOM tree from window to the target (capturing), then back up to window (bubbling), and listeners on every ancestor along the way get a turn unless one calls stopPropagation().

Think of it like this

A letter delivered in a block of flats. The postman enters the building, goes up to your floor and walks to your door: that's the capturing phase, from the outside in. He hands the letter to you: the target phase. Then news of the letter spreads back out: your family hears, then the neighbours on your floor, then the whole building: that's bubbling, from the inside out. Anyone on the way can react, and anyone can say "keep this quiet" to stop it spreading further.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Dispatch
Send an event to a target so its listeners run.
Propagation
The event's journey through the DOM tree, from window to the target and back.
Propagation path
The list of elements the event visits, fixed when dispatch starts.
Capturing phase
Phase 1: the event travels down from window to the target's parent.
Target phase
Phase 2: the event is at the element where it started.
Bubbling phase
Phase 3: the event travels back up from the target's parent to window.
currentTarget
The element whose listener is running at this moment. Changes at every step.
stopPropagation
A method that stops the event from reaching the next element in the path.

Step by step

01Three boxes inside each other

Here is a small tree. Clicking the button also clicks the middle box and the outer box, because the button is inside them.

index.htmlwhole filehtml
<div id="outer">
  <div id="middle">
    <button id="inner">Click me</button>
  </div>
</div>

02The browser computes the path

Before any listener runs, the browser lists the ancestors of the target: window, document, html, body, #outer, #middle, and the target #inner. The event will visit them down, then up.

The browser computes the pathdiagram
Rendering diagram…

03Capture listeners run on the way down

A listener added with { capture: true } (or true as the third argument) runs in phase 1. Few apps need this; it's useful when a parent must see an event before any child can stop it.

app.jswhole filejavascript
outer.addEventListener("click", () => console.log("outer, going down"), { capture: true });

04Normal listeners run on the way up

Without options, a listener runs in the target phase (if it is on the target) or in the bubbling phase (if it is on an ancestor). This is what you use 99% of the time.

app.jswhole filejavascript
outer.addEventListener("click", () => console.log("outer, coming up"));

05Know where you are

Inside any listener, event.target is the button, event.currentTarget is the element this listener is on, and event.eventPhase says 1, 2 or 3.

06Stop it only when you must

event.stopPropagation() in the button's listener means #middle, #outer, document and window never hear about the click on the way up.

app.jswhole filejavascript
inner.addEventListener("click", (event) => {
  event.stopPropagation();
});

Try it yourself

  1. 1

    Remove the capture listeners

    In the first example, delete the line with { capture: true }. Predict how many lines print now and in what order.

  2. 2

    Click outside the label

    In the second example, change .label to .icon. Which line changes? Then click the button's padding yourself: what is target now?

  3. 3

    Let the card hear the delete

    In the third example, delete e.stopPropagation();. Predict the new output. Then, instead of stopping, change the card listener to if (e.target.closest("button")) return; before its log. Does that give the same result without hiding the event from everyone else?

Code & diagrams

Watch an event go down and up New tab

On the target itself both listeners run in phase 2, capture first. Click the middle box yourself: which lines disappear?

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

capture listener on outer | phase 1
capture listener on middle | phase 1
capture listener on inner | phase 2
bubble listener on inner | phase 2
bubble listener on middle | phase 3
bubble listener on outer | phase 3
target versus currentTarget New tab

Real users click the text or the icon, so `event.target` is often a child. Use `currentTarget` (or `closest`) to get the element you attached the listener to.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

target: <span.label>
currentTarget: <button#buy>
this: <button#buy>
closest button: <button#buy>
after dispatch, currentTarget is null
after dispatch, target is still <span.label>
stopPropagation versus stopImmediatePropagation New tab

Surprise in the last line: a `MouseEvent` you construct yourself doesn't bubble either unless you pass `{ bubbles: true }`. Only events the browser fires (and `el.click()`) set it for you.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

del: delete the card
del: second listener still runs
---
pin: pinned
---
click bubbles? false | ping bubbles? false

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use target when you meant currentTarget

On a button containing <span>Buy</span>, write event.target.dataset.id in the button's listener, where data-id is on the button.

terminal
$ Console
── what you'll see ──
undefined (only when the user clicks exactly on the text)

Break #2

Stop propagation and break the outside-click menu

A dropdown closes when document hears a click outside it. Somewhere else, a widget calls event.stopPropagation() in its click listener.

terminal
$ the page shows
── what you'll see ──
Clicking that widget leaves the dropdown open; clicking anywhere else closes it.

Myth vs fact

Myth

stopPropagation() stops the link from opening or the form from sending.

Fact

It only stops the event's journey to other elements. The browser's default action still happens; cancel that with preventDefault().

Myth

Events start on the target and only go up.

Fact

Every event starts at window and travels down first (capturing). You don't see that phase because normal listeners skip it, but { capture: true } listeners run there.

Myth

focus bubbles like click.

Fact

focus and blur don't bubble. Their bubbling twins are focusin and focusout, which is why form-wide focus tracking listens for those.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The propagation path is computed once, before the first listener runs. Removing an ancestor from the DOM inside a listener doesn't stop the event reaching it; listeners added to an element during dispatch to that same element don't run for the current event.

  • ▸

    Since Chrome 89 (2021), capture listeners on the target run before non-capture ones, matching Firefox and the DOM standard. Older Chrome ran target listeners strictly in registration order, so old articles may describe a different order.

  • ▸

    React 17+ attaches its own listeners to the root container, not to document, and Shadow DOM retargets events: when an event leaves a shadow root, target becomes the host element, so outside code can't see the component's internals. event.composedPath() returns the full path, including shadow nodes (for open shadow roots).

  • ▸

    event.cancelBubble = true is the legacy spelling of stopPropagation() and still works. return false only cancels the default action in on... properties; in addEventListener listeners it does nothing.

Remember this

  1. 1

    The DOM is a tree (Topic 8.1), so the button you click sits inside a <div>, inside <body>, inside <html>, inside document, under window. When an event is dispatched (sent) to a target, the browser first works out this propagation path: the list of ancestors from window down to the target. It is fixed at the start, so moving or removing elements during the event doesn't change who gets it.

  2. 2

    The trip has three phases, stored as numbers in event.eventPhase. 1, capturing: from window down towards the target, calling listeners registered with { capture: true }. 2, at target: on the target element itself. 3, bubbling: back up from the target's parent to window, calling normal listeners (the default, capture: false). On the target itself, capture listeners run first, then non-capture ones (the rule in Chrome since 2021 and in all current browsers). Once the event finishes, eventPhase is 0.

  3. 3

    **event.target is where the event started: the deepest element clicked, and it stays the same for the whole journey. event.currentTarget** is the element whose listener is running right now, and it changes at every step. If you click the <span> inside a button with a listener on the button, target is the span and currentTarget is the button. currentTarget is only set while listeners run; read it later (in a timer) and it is null.

  4. 4

    Most events bubble: click, input, change, submit, keydown, pointerdown and so on. Some don't, because bubbling would make no sense for them: focus and blur (use focusin/focusout instead, which do bubble), mouseenter and mouseleave (use mouseover/mouseout), load and error on images and scripts, and scroll on elements. Check event.bubbles. Non-bubbling events still go through the capturing phase, so a capture listener on a parent can still catch them.

  5. 5

    event.stopPropagation() stops the event moving on to the next element in the path, but other listeners on the current element still run. event.stopImmediatePropagation() also stops the remaining listeners on the current element. Neither one cancels the browser's default action (a link still opens); that is preventDefault(), in Topic 9.4.

  6. 6

    Use stopping sparingly. Other code may rely on seeing events bubble up: analytics, a menu that closes when you click anywhere outside it, or a framework's delegated listener on the root. A stopped event is invisible to all of them. A gentler option is to check instead of stop: in the parent listener, return early when event.target.closest("button") shows the click came from a button you've already handled.

Explain it without notes

01

Describe the three phases of event propagation.

02

What is the difference between event.target and event.currentTarget?

03

What is the difference between stopPropagation, stopImmediatePropagation and preventDefault?

04

Why is calling stopPropagation often considered a bad idea?

Practice

01

Put a <li> inside a <ul id="menu">. Add a listener on the list that logs eventPhase and the tag name of target. Click the <li> from code.

02

On a page with <div id="panel"><button id="close">×</button></div>, the panel logs panel clicked and the button logs closed. Make clicking the button log only closed, without calling stopPropagation.

Trade-offs

  • ↔

    stopPropagation is a quick fix for a parent reacting when it shouldn't, but it breaks every listener above; checking event.target in the parent keeps events visible at the cost of a condition in each parent listener.

  • ↔

    Capture listeners let a parent act before any child (for example, to block clicks during a loading state), but they run on every event in the subtree and surprise readers who expect the normal bubbling order.

Done when you can

  • I can describe capturing, target and bubbling phases and read eventPhase.

  • I can tell target from currentTarget and use closest to find the element I care about.

  • I know which common events don't bubble and their bubbling alternatives.

  • I can choose between stopPropagation, stopImmediatePropagation and checking the target.

  • I know stopping propagation doesn't cancel the default action.