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
windowto 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
windowto 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.
<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.
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.
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.
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.
inner.addEventListener("click", (event) => {
event.stopPropagation();
});Try it yourself
- 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
Click outside the label
In the second example, change
.labelto.icon. Which line changes? Then click the button's padding yourself: what istargetnow? - 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 toif (e.target.closest("button")) return;before its log. Does that give the same result without hiding the event from everyone else?
Code & diagrams
On the target itself both listeners run in phase 2, capture first. Click the middle box yourself: which lines disappear?
Preview
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 3Real 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
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>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
Console
Nothing logged yet.Expected console output
del: delete the card
del: second listener still runs
---
pin: pinned
---
click bubbles? false | ping bubbles? falseBreak 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.
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.
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,targetbecomes 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 = trueis the legacy spelling ofstopPropagation()and still works.return falseonly cancels the default action inon...properties; inaddEventListenerlisteners it does nothing.
Remember this
- 1
The DOM is a tree (Topic 8.1), so the button you click sits inside a
<div>, inside<body>, inside<html>, insidedocument, underwindow. When an event is dispatched (sent) to a target, the browser first works out this propagation path: the list of ancestors fromwindowdown to the target. It is fixed at the start, so moving or removing elements during the event doesn't change who gets it. - 2
The trip has three phases, stored as numbers in
event.eventPhase. 1, capturing: fromwindowdown 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 towindow, 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,eventPhaseis 0. - 3
**
event.targetis 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,targetis the span andcurrentTargetis the button.currentTargetis only set while listeners run; read it later (in a timer) and it isnull. - 4
Most events bubble:
click,input,change,submit,keydown,pointerdownand so on. Some don't, because bubbling would make no sense for them:focusandblur(usefocusin/focusoutinstead, which do bubble),mouseenterandmouseleave(usemouseover/mouseout),loadanderroron images and scripts, andscrollon elements. Checkevent.bubbles. Non-bubbling events still go through the capturing phase, so a capture listener on a parent can still catch them. - 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 ispreventDefault(), in Topic 9.4. - 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
Describe the three phases of event propagation.
What is the difference between event.target and event.currentTarget?
What is the difference between stopPropagation, stopImmediatePropagation and preventDefault?
Why is calling stopPropagation often considered a bad idea?
Practice
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.
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
- ↔
stopPropagationis a quick fix for a parent reacting when it shouldn't, but it breaks every listener above; checkingevent.targetin 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
targetfromcurrentTargetand useclosestto find the element I care about.I know which common events don't bubble and their bubbling alternatives.
I can choose between
stopPropagation,stopImmediatePropagationand checking the target.I know stopping propagation doesn't cancel the default action.