Skip to main content

Skillber v1.0 is here!

Learn more

Event Propagation

Checking access...

When you click an element, you’re actually clicking every ancestor of that element. The event doesn’t just fire on the target β€” it travels through the DOM tree in three phases.

The Three Phases

Capturing phase: document β†’ html β†’ body β†’ div β†’ button
Target phase: button
Bubbling phase: button β†’ div β†’ body β†’ html β†’ document
<div id="outer">
<div id="inner">
<button id="btn">Click Me</button>
</div>
</div>
// Add listeners for all three phases to see the order
document.querySelectorAll("*").forEach((el) => {
// Bubbling phase (default: capture=false)
el.addEventListener("click", (e) => {
console.log(`BUBBLE: ${el.id || el.tagName}`);
});
// Capturing phase (capture=true)
el.addEventListener("click", (e) => {
console.log(`CAPTURE: ${el.id || el.tagName}`);
}, true);
});

Clicking the button logs:

CAPTURE: html
CAPTURE: body
CAPTURE: outer
CAPTURE: inner
CAPTURE: btn
BUBBLE: btn
BUBBLE: inner
BUBBLE: outer
BUBBLE: body
BUBBLE: html

Phase 1: Capturing (Capture Phase)

The event travels from document down to the target element. Listeners with { capture: true } fire during this phase.

Phase 2: At Target

The event reaches the element that was actually clicked. All listeners on that element fire (both capture and bubble).

Phase 3: Bubbling

The event travels back up from the target to document. This is the default phase β€” most event listeners fire here.

Default: Bubbling

By default, addEventListener attaches to the bubbling phase:

// These are equivalent:
button.addEventListener("click", handler);
button.addEventListener("click", handler, false); // capture: false

Most events bubble. Some do not:

  • focus / blur (use focusin / focusout instead)
  • mouseenter / mouseleave (use mouseover / mouseout)
  • scroll (on the element itself)
  • load / unload / abort / error
  • reset / submit

Stopping Propagation

stopPropagation()

Prevents the event from travelling further:

inner.addEventListener("click", (event) => {
event.stopPropagation();
console.log("Inner clicked β€” outer won't fire");
});
outer.addEventListener("click", () => {
console.log("This never runs when inner is clicked");
});

This stops the event from bubbling up (or capturing down, depending on which phase you’re in).

stopImmediatePropagation()

Also prevents other listeners on the same element from firing:

button.addEventListener("click", (event) => {
event.stopImmediatePropagation();
console.log("First handler β€” blocks others");
});
button.addEventListener("click", () => {
console.log("This never runs");
});
button.addEventListener("click", () => {
console.log("This also never runs");
});

Caution

Use stopPropagation() sparingly. It can break event delegation patterns and make debugging harder. Often, checking event.target is a better approach than stopping propagation.

Event Delegation (Deep Dive)

Event delegation leverages bubbling to handle events efficiently:

<table id="data-table">
<tr><td data-id="1">Alice</td><td>Engineer</td></tr>
<tr><td data-id="2">Bob</td><td>Designer</td></tr>
<tr><td data-id="3">Charlie</td><td>Manager</td></tr>
</table>
// ❌ Without delegation β€” add listener per row
document.querySelectorAll("#data-table tr").forEach((row) => {
row.addEventListener("click", () => {
const id = row.querySelector("td:first-child").dataset.id;
console.log("Row clicked:", id);
});
});
// βœ… With delegation β€” single listener on table
document.getElementById("data-table").addEventListener("click", (event) => {
const row = event.target.closest("tr"); // find ancestor <tr>
if (row) {
const id = row.querySelector("td").dataset.id;
console.log("Row clicked:", id);
}
});

Delegation Pattern Template

parent.addEventListener("click", (event) => {
const target = event.target.closest(".child-selector");
if (!target) return; // not the element we care about
// Handle the event
console.log("Matched element:", target);
});

Multiple Action Delegation

<div id="toolbar">
<button data-action="save">πŸ’Ύ Save</button>
<button data-action="delete">πŸ—‘οΈ Delete</button>
<button data-action="edit">✏️ Edit</button>
<button data-action="export">πŸ“€ Export</button>
</div>
const actions = {
save: () => console.log("Saving..."),
delete: () => console.log("Deleting..."),
edit: () => console.log("Editing..."),
export: () => console.log("Exporting..."),
};
document.getElementById("toolbar").addEventListener("click", (event) => {
const action = event.target.dataset.action;
if (action && actions[action]) {
actions[action]();
}
});

Practical Patterns

<div id="modal-overlay">
<div id="modal-content">
<h2>Modal Title</h2>
<p>Modal content...</p>
<button id="close-modal">Close</button>
</div>
</div>
// Close modal when clicking the overlay (background)
document.getElementById("modal-overlay").addEventListener("click", (event) => {
// Only close if the overlay itself was clicked, not its children
if (event.target === event.currentTarget) {
closeModal();
}
});
// Alternative: use closest to check
document.getElementById("modal-overlay").addEventListener("click", (event) => {
if (!event.target.closest("#modal-content")) {
closeModal(); // clicked outside modal content
}
});
<nav id="nav">
<div class="dropdown">
<button class="dropdown-toggle">Products β–Ύ</button>
<ul class="dropdown-menu hidden">
<li><a href="/products/saas">SaaS</a></li>
<li><a href="/products/api">API</a></li>
<li><a href="/products/mobile">Mobile</a></li>
</ul>
</div>
</nav>
// Open dropdown on toggle click
document.querySelectorAll(".dropdown-toggle").forEach((btn) => {
btn.addEventListener("click", (event) => {
event.stopPropagation(); // prevent document listener
const menu = btn.nextElementSibling;
menu.classList.toggle("hidden");
});
});
// Close all dropdowns when clicking anywhere else
document.addEventListener("click", () => {
document.querySelectorAll(".dropdown-menu").forEach((menu) => {
menu.classList.add("hidden");
});
});

Performance: Bubbling vs Direct

<ul id="big-list">
<!-- 10,000 list items -->
</ul>
// ❌ 10,000 listeners β€” slow to create, memory heavy
for (let i = 0; i < 10000; i++) {
const li = document.createElement("li");
li.addEventListener("click", () => console.log(li.textContent));
list.appendChild(li);
}
// βœ… 1 listener β€” fast, minimal memory
list.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (li) console.log(li.textContent);
});

Events That Don’t Bubble

For non-bubbling events, use the bubbling variant or capture phase:

// focus/blur don't bubble
form.addEventListener("focus", handler); // ❌ doesn't work on children
// Solution 1: use focusin/focusout (bubble)
form.addEventListener("focusin", handler); // βœ… bubbles
// Solution 2: capture phase
form.addEventListener("focus", handler, true); // βœ… captures

Quick Reference

MethodEffect
event.stopPropagation()Stop event from travelling to other elements
event.stopImmediatePropagation()Also stop other listeners on the same element
event.preventDefault()Cancel default browser behaviour
event.targetThe element that received the event
event.currentTargetThe element the listener is attached to
element.closest(selector)Find nearest ancestor matching selector

Practice Exercises

  1. Propagation visualiser: Create a nested structure: div#a > div#b > div#c. Add click listeners that log their ID and phase (capture/bubble). Click on #c and trace the order.

  2. Accordion with delegation: Build an accordion widget where clicking a header expands/collapses its content. Use a single event listener on the parent container.

  3. Dynamic list with delete buttons: Create a list where each item has a β€œDelete” button. Use event delegation so the delete buttons work even on newly added items.