docs/beyond/concepts/event-bubbling-capturing.mdx
You click a button inside a <div>, but both the button's handler AND the div's handler fire. Why? Or you add a click listener to a parent element, and it somehow catches clicks on all its children. How does that work?
The answer lies in event propagation — the way events travel through the DOM tree. Understanding this unlocks powerful patterns like event delegation and helps you avoid frustrating bugs.
// Click a button nested inside a div
document.querySelector('.parent').addEventListener('click', () => {
console.log('Parent clicked!') // This fires too!
})
document.querySelector('.child-button').addEventListener('click', () => {
console.log('Button clicked!') // This fires first
})
// Click the button → Output:
// "Button clicked!"
// "Parent clicked!" — Wait, I only clicked the button!
This happens because of event bubbling — one of the three phases every DOM event goes through.
<Info> **What you'll learn in this guide:** - The three phases of event propagation (capturing, target, bubbling) - Why events "bubble up" to parent elements - How to listen during the capturing phase with `addEventListener` - The difference between `stopPropagation()` and `stopImmediatePropagation()` - Which events don't bubble and their alternatives - When capturing is actually useful (it's rare, but important) - Common mistakes that break event handling </Info> <Warning> **Prerequisite:** This guide assumes you're comfortable with basic [DOM manipulation](/concepts/dom) and event listeners. If `addEventListener` is new to you, read that guide first! </Warning>Event propagation is the process by which an event travels through the DOM tree when triggered on an element. Instead of the event only affecting the element you clicked, it travels through the element's ancestors in a specific order, giving each one a chance to respond.
According to the W3C UI Events specification, every DOM event goes through three phases:
window to the target elementwindow┌─────────────────────────────────────────────────────────────────────────┐
│ THE THREE PHASES OF EVENT PROPAGATION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ PHASE 1: CAPTURING PHASE 3: BUBBLING │
│ (Top → Down) (Bottom → Up) │
│ │
│ window window │
│ ↓ ↑ │
│ document document │
│ ↓ ↑ │
│ <html> <html> │
│ ↓ ↑ │
│ <body> <body> │
│ ↓ ↑ │
│ <div> <div> │
│ ↓ ↑ │
│ <button> ←── PHASE 2: TARGET ──→ <button> │
│ │
│ Handlers with Handlers with │
│ capture: true capture: false (default) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
By default, event listeners fire during the bubbling phase (Phase 3). Can I Use data confirms that addEventListener with capture support is available in all modern browsers since IE9. That's why when you click a button, the button's handler fires first, then its parent's handler, then its grandparent's, and so on up to window.
Think of event propagation like an announcement traveling through a restaurant:
Capturing phase: The manager walks from the entrance, through the dining room, past each table, until reaching your table to deliver a message. Every employee along the way hears it first.
Target phase: The message reaches you directly.
Bubbling phase: After you receive it, anyone who was listening nearby (your table, then nearby tables, then the whole dining room) can also respond — but in reverse order, starting with the closest.
┌─────────────────────────────────────────────────────────────────────────┐
│ EVENT PROPAGATION IN A RESTAURANT │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ENTRANCE (window) │
│ │ │
│ ↓ ─── Capturing ────────────────────────────────┐ │
│ DINING ROOM (document) │ │
│ │ │ │
│ ↓ │ │
│ SECTION A (parent div) │ │
│ │ │ │
│ ↓ │ │
│ YOUR TABLE (button) ◄── TARGET ──► │ │
│ │ │ │
│ ↑ │ │
│ SECTION A ─── Bubbling ─────────────────────────────┘ │
│ ↑ │
│ DINING ROOM │
│ ↑ │
│ ENTRANCE │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Most of the time, you only care about the bubbling phase. But knowing about capturing helps you understand why events behave the way they do.
Let's see bubbling with a concrete example. We'll create nested elements and add click handlers to each:
// HTML: <div class="grandparent">
// <div class="parent">
// <button class="child">Click me</button>
// </div>
// </div>
document.querySelector('.grandparent').addEventListener('click', () => {
console.log('Grandparent clicked')
})
document.querySelector('.parent').addEventListener('click', () => {
console.log('Parent clicked')
})
document.querySelector('.child').addEventListener('click', () => {
console.log('Child clicked')
})
// Click the button → Output:
// "Child clicked"
// "Parent clicked"
// "Grandparent clicked"
The event starts at the button (the target), then bubbles up through each ancestor. This is the default behavior for most events.
Bubbling enables event delegation — attaching a single listener to a parent element instead of individual listeners on many children:
// ❌ INEFFICIENT - Listener on every button
document.querySelectorAll('.btn').forEach(btn => {
btn.addEventListener('click', handleClick)
})
// ✓ EFFICIENT - One listener on the parent
document.querySelector('.button-container').addEventListener('click', (e) => {
// e.target is the element that was actually clicked
if (e.target.matches('.btn')) {
handleClick(e)
}
})
This pattern works because clicks on buttons bubble up to the container. Learn more in our Event Delegation guide.
By default, addEventListener listens during bubbling. To listen during capturing (when the event travels DOWN), pass { capture: true } or just true as the third argument:
// Listen during BUBBLING (default)
element.addEventListener('click', handler)
element.addEventListener('click', handler, false)
element.addEventListener('click', handler, { capture: false })
// Listen during CAPTURING
element.addEventListener('click', handler, true)
element.addEventListener('click', handler, { capture: true })
Here's what changes when you use capturing:
document.querySelector('.parent').addEventListener('click', () => {
console.log('Parent - capturing')
}, true) // ← capture: true
document.querySelector('.child').addEventListener('click', () => {
console.log('Child - target')
})
document.querySelector('.parent').addEventListener('click', () => {
console.log('Parent - bubbling')
}) // ← capture: false (default)
// Click the child → Output:
// "Parent - capturing" ← Fires FIRST (on the way down)
// "Child - target"
// "Parent - bubbling" ← Fires LAST (on the way up)
eventPhase PropertyYou can check which phase an event is in using the event.eventPhase property:
element.addEventListener('click', (event) => {
console.log(event.eventPhase)
// 1 = CAPTURING_PHASE
// 2 = AT_TARGET
// 3 = BUBBLING_PHASE
})
| Value | Constant | Meaning |
|---|---|---|
| 0 | Event.NONE | Event is not being processed |
| 1 | Event.CAPTURING_PHASE | Event is traveling down to target |
| 2 | Event.AT_TARGET | Event is at the target element |
| 3 | Event.BUBBLING_PHASE | Event is bubbling up from target |
document.querySelector('.parent').addEventListener('click', (e) => {
const phases = ['NONE', 'CAPTURING', 'AT_TARGET', 'BUBBLING']
console.log(`Phase: ${phases[e.eventPhase]}`)
}, true)
document.querySelector('.parent').addEventListener('click', (e) => {
const phases = ['NONE', 'CAPTURING', 'AT_TARGET', 'BUBBLING']
console.log(`Phase: ${phases[e.eventPhase]}`)
})
// Click the parent directly → Output:
// "Phase: AT_TARGET"
// "Phase: AT_TARGET"
// (Both fire at target phase when clicking the element directly)
// Click a child element → Output:
// "Phase: CAPTURING"
// "Phase: BUBBLING"
event.target vs event.currentTargetWhen events bubble, you need to distinguish between:
event.target — The element that triggered the event (what was actually clicked)event.currentTarget — The element that has the listener (where the handler is attached)document.querySelector('.parent').addEventListener('click', (e) => {
console.log('target:', e.target.className) // What was clicked
console.log('currentTarget:', e.currentTarget.className) // Where listener is
})
// Click on a child button with class "child"
// target: "child" ← The button you clicked
// currentTarget: "parent" ← The element with the listener
This distinction is crucial for event delegation:
// Event delegation pattern
document.querySelector('.list').addEventListener('click', (e) => {
// e.target might be the <li>, <span>, or any child
// e.currentTarget is always .list
// Find the list item (even if user clicked a child)
const listItem = e.target.closest('li')
if (listItem) {
console.log('Clicked item:', listItem.textContent)
}
})
Sometimes you need to stop an event from traveling further. JavaScript provides two methods:
stopPropagation()event.stopPropagation() stops the event from traveling to other elements, but other handlers on the current element still run:
document.querySelector('.parent').addEventListener('click', () => {
console.log('Parent handler') // This WON'T fire
})
document.querySelector('.child').addEventListener('click', (e) => {
console.log('Child handler 1')
e.stopPropagation() // Stop bubbling here
})
document.querySelector('.child').addEventListener('click', () => {
console.log('Child handler 2') // This STILL fires
})
// Click child → Output:
// "Child handler 1"
// "Child handler 2" ← Still runs (same element)
// (Parent handler never fires)
stopImmediatePropagation()event.stopImmediatePropagation() stops the event AND prevents other handlers on the same element from running:
document.querySelector('.child').addEventListener('click', (e) => {
console.log('Child handler 1')
e.stopImmediatePropagation() // Stop everything
})
document.querySelector('.child').addEventListener('click', () => {
console.log('Child handler 2') // This WON'T fire
})
// Click child → Output:
// "Child handler 1"
// (Nothing else runs)
┌─────────────────────────────────────────────────────────────────────────┐
│ stopPropagation vs stopImmediatePropagation │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ stopPropagation() stopImmediatePropagation() │
│ ───────────────── ────────────────────────── │
│ │
│ ✓ Stops bubbling/capturing ✓ Stops bubbling/capturing │
│ ✓ Other handlers on SAME ✗ Other handlers on SAME │
│ element still run element DON'T run │
│ │
│ Use when: You want to stop Use when: You want to completely │
│ propagation but allow other cancel all further event handling │
│ handlers on this element │
│ │
└─────────────────────────────────────────────────────────────────────────┘
stopPropagation() vs preventDefault()Don't confuse propagation with default behavior:
| Method | What it does | Example |
|---|---|---|
stopPropagation() | Stops event from reaching other elements | Parent's click handler won't fire |
preventDefault() | Stops the browser's default action | Link won't navigate, form won't submit |
// They do different things!
link.addEventListener('click', (e) => {
e.preventDefault() // Link won't navigate
// But event STILL bubbles to parent!
})
link.addEventListener('click', (e) => {
e.stopPropagation() // Parent handlers won't fire
// But link STILL navigates!
})
link.addEventListener('click', (e) => {
e.preventDefault() // Don't navigate
e.stopPropagation() // Don't bubble
// Now it does neither
})
Most events bubble, but some don't. As MDN's event reference documents, each event specifies whether it bubbles in its specification. Here are the common ones:
| Event | Bubbles? | Bubbling Alternative |
|---|---|---|
click, mousedown, keydown | Yes | — |
focus | No | focusin |
blur | No | focusout |
mouseenter | No | mouseover |
mouseleave | No | mouseout |
load, unload, scroll | No | — |
resize | No | — |
If you need delegation for non-bubbling events, use their bubbling alternatives:
// ❌ WON'T WORK - focus doesn't bubble
form.addEventListener('focus', (e) => {
console.log('Something focused:', e.target)
})
// ✓ WORKS - focusin bubbles
form.addEventListener('focusin', (e) => {
console.log('Something focused:', e.target)
})
// ❌ WON'T WORK - mouseenter doesn't bubble
container.addEventListener('mouseenter', (e) => {
console.log('Mouse entered:', e.target)
})
// ✓ WORKS - mouseover bubbles (but fires more often)
container.addEventListener('mouseover', (e) => {
console.log('Mouse over:', e.target)
})
Capturing is rarely needed, but here are legitimate use cases:
// Log every click before any handler runs
document.addEventListener('click', (e) => {
console.log('Click detected on:', e.target)
}, true) // Capture phase - fires first
let disableClicks = false
document.addEventListener('click', (e) => {
if (disableClicks) {
e.stopPropagation()
console.log('Click blocked!')
}
}, true) // Must use capture to intercept before target
Some browsers don't fire events on disabled form elements, but capturing on a parent can catch them:
form.addEventListener('click', (e) => {
if (e.target.disabled) {
console.log('Clicked disabled element')
}
}, true)
```javascript
// Adding with capture
element.addEventListener('click', handler, true)
// ❌ WRONG - Won't remove the listener
element.removeEventListener('click', handler)
// ✓ CORRECT - Must match capture setting
element.removeEventListener('click', handler, true)
```
```javascript
// Some library sets up a document click handler for modals
document.addEventListener('click', closeAllModals)
// Your code stops propagation
button.addEventListener('click', (e) => {
e.stopPropagation() // Now modals never close!
doSomething()
})
// ✓ Better: Check if you need to stop, or use a different approach
button.addEventListener('click', (e) => {
doSomething()
// Don't stop propagation unless absolutely necessary
})
```
```javascript
// ❌ WRONG - target might be a child element
list.addEventListener('click', (e) => {
e.target.classList.add('selected') // Might select a <span> inside <li>
})
// ✓ CORRECT - Find the actual list item
list.addEventListener('click', (e) => {
const item = e.target.closest('li')
if (item) {
item.classList.add('selected')
}
})
```
```javascript
// ❌ WRONG - focus doesn't bubble
form.addEventListener('focus', highlightField)
// ✓ CORRECT - Use the bubbling version
form.addEventListener('focusin', highlightField)
```
Events travel in three phases — Capturing (down), target (at element), bubbling (up). Most handlers fire during bubbling.
Bubbling is the default — When you click a child, parent handlers fire too. This enables event delegation.
Use { capture: true } for capturing — Add as third argument to addEventListener to catch events on the way down.
target vs currentTarget — target is what was clicked, currentTarget is where the handler lives.
stopPropagation() stops travel — Prevents the event from reaching other elements, but other handlers on the same element still run.
stopImmediatePropagation() stops everything — Prevents all further handling, even on the same element.
Don't confuse with preventDefault() — That stops browser default actions (link navigation, form submission), not propagation.
Some events don't bubble — focus, blur, mouseenter, mouseleave. Use their bubbling alternatives for delegation.
Use stopPropagation() sparingly — It breaks event delegation and can cause hard-to-debug issues.
Remember capture when removing listeners — removeEventListener must match the capture setting used in addEventListener.
1. **Capturing phase** — Event travels from `window` down through ancestors to the target element
2. **Target phase** — Event is at the element that triggered it
3. **Bubbling phase** — Event travels from target back up through ancestors to `window`
By default, event listeners fire during the bubbling phase.
Pass `true` or `{ capture: true }` as the third argument to `addEventListener`:
```javascript
element.addEventListener('click', handler, true)
// or
element.addEventListener('click', handler, { capture: true })
```
- `event.target` — The element that **triggered** the event (what the user actually clicked)
- `event.currentTarget` — The element that **has the event listener** attached
They're the same when you click directly on an element with a listener, but different when events bubble up from children.
- `stopPropagation()` — Stops the event from reaching other elements, but other handlers on the **same element** still run
- `stopImmediatePropagation()` — Stops everything, including other handlers on the same element
```javascript
// With stopPropagation, both child handlers run
// With stopImmediatePropagation, only the first child handler runs
```
The `focus` event doesn't bubble! For delegation with focus events, use `focusin` instead:
```javascript
// ❌ Won't work - focus doesn't bubble
form.addEventListener('focus', handler)
// ✓ Works - focusin bubbles
form.addEventListener('focusin', handler)
```
The listener won't be removed! You must match the capture setting:
```javascript
element.addEventListener('click', handler, true)
element.removeEventListener('click', handler) // ❌ Doesn't remove
element.removeEventListener('click', handler, true) // ✓ Removes correctly
```