🌊 Event Bubbling, Capturing, and Delegation
An event doesn't just fire on the element you clicked — it takes a journey down and back up the DOM tree. Understanding that journey unlocks event delegation: a single listener on a parent that efficiently handles hundreds of children, even ones that don't exist yet. This is one of the highest-leverage patterns in front-end JavaScript.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Describe the three phases of event flow: capturing, target, bubbling
- Register listeners for the capturing phase and predict the firing order
- Control flow with
stopPropagation()and distinguish it frompreventDefault() - Implement event delegation using
event.target.closest() - Recognize non-bubbling events and their bubbling alternatives (
focusin,mouseover)
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a to-do list where one delegated listener handles complete, delete, and add.
In This Lesson
What Is Event Propagation?
When you click a button nested inside several containers, the click doesn't only fire on the button. The browser sends the event on a round trip through every ancestor, giving each a chance to respond. That round trip is event propagation.
💡 The stadium wave. Picture a wave in a packed stadium. It starts at the top rows (the document), rolls down to the section where it began (the target) — that's capturing — and then rolls back up to the top — that's bubbling. Anyone in a row the wave passes can join in on the way down or the way back up.
📖 Key Terms
Propagation: the event's journey through the DOM tree.
Capturing: the downward leg, from the document toward the target.
Bubbling: the upward leg, from the target back toward the document.
Delegation: handling child events on a shared ancestor by inspecting event.target.
The Three Phases
Every event moves through three phases in order: it captures down to the target, reaches the target, then bubbles back up.
Consider this markup and listeners on all three elements, in both phases:
<div id="outer">
<div id="middle">
<button id="inner">Click Me</button>
</div>
</div>
const outer = document.getElementById('outer');
const middle = document.getElementById('middle');
const inner = document.getElementById('inner');
// Capturing phase — third argument (or { capture: true })
outer.addEventListener('click', () => console.log('1 capture: outer'), true);
middle.addEventListener('click', () => console.log('2 capture: middle'), true);
inner.addEventListener('click', () => console.log('3 capture: inner'), true);
// Bubbling phase — the default
inner.addEventListener('click', () => console.log('4 bubble: inner'));
middle.addEventListener('click', () => console.log('5 bubble: middle'));
outer.addEventListener('click', () => console.log('6 bubble: outer'));
Console output when the button is clicked:
1 capture: outer
2 capture: middle
3 capture: inner
4 bubble: inner
5 bubble: middle
6 bubble: outer
💡 The default is bubbling
Unless you pass true or { capture: true }, listeners run during the bubbling phase. Capturing is occasionally useful (e.g. intercepting an event before descendants see it), but 95% of real code relies on bubbling — which is exactly what delegation needs.
Controlling Propagation
stopPropagation()
Halts the event's journey so ancestor listeners never see it:
middle.addEventListener('click', (event) => {
event.stopPropagation(); // outer's handler will NOT run
console.log('middle handled it');
});
preventDefault()
Cancels the browser's default action but lets the event keep travelling:
form.addEventListener('submit', (event) => {
event.preventDefault(); // no page reload…
// …but the submit event still bubbles to ancestor listeners
});
⚠️ A common misconception
preventDefault() and stopPropagation() are unrelated. One cancels the default action; the other stops propagation. When you both want to suppress navigation and keep the event from bubbling, call both:
link.addEventListener('click', (event) => {
event.preventDefault(); // don't navigate
event.stopPropagation(); // don't let ancestors react
showContent(link.getAttribute('href'));
});
✅ Often you don't need stopPropagation() at all
A modal that closes on background clicks is a classic case where checking event.target is cleaner than stopping propagation:
overlay.addEventListener('click', (event) => {
// Only close if the overlay itself — not its content — was clicked
if (event.target === overlay) closeModal();
});
Because the check compares event.target, clicks inside the content naturally don't close the modal — no stopPropagation() required.
The Delegation Pattern
Because events bubble, you can attach one listener to a parent and use event.target to figure out which child was interacted with. This is event delegation — the reason bubbling matters so much in practice.
ONE click listener] --- H{Inspect event.target} C1[Child 1] -.bubbles.-> P C2[Child 2] -.bubbles.-> P C3[Future child
added later] -.bubbles.-> P H -->|matches .edit| E[Edit action] H -->|matches .delete| D[Delete action] H -->|matches .item| S[Select action]
Before: a listener per element
// Inefficient, and blind to elements added after this runs
document.querySelectorAll('.menu-item').forEach((item) => {
item.addEventListener('click', () => navigate(item.dataset.section));
});
After: one delegated listener
document.getElementById('menu').addEventListener('click', (event) => {
const item = event.target.closest('.menu-item');
if (!item) return; // clicked empty space? ignore
navigate(item.dataset.section);
});
📖 Why closest() is essential
event.target is the deepest element clicked — maybe an icon or a <span> inside your button. event.target.closest('.menu-item') walks up from there to the nearest matching ancestor (or the element itself), so your logic works no matter which inner element received the click. It returns null when nothing matches, which makes the guard clause clean.
Routing multiple actions with data attributes
A scalable delegation style: put the intended action in a data-action attribute and switch on it.
<div id="toolbar">
<button data-action="save">Save</button>
<button data-action="delete">Delete</button>
<button data-action="export" data-format="pdf">Export PDF</button>
</div>
document.getElementById('toolbar').addEventListener('click', (event) => {
const btn = event.target.closest('[data-action]');
if (!btn) return;
switch (btn.dataset.action) {
case 'save': saveDocument(); break;
case 'delete': deleteDocument(); break;
case 'export': exportAs(btn.dataset.format); break;
}
});
✅ Benefits of delegation
- Memory: one listener instead of hundreds.
- Dynamic content: works for elements added after the listener was set up — no re-binding.
- Less code: add and remove children freely; the parent keeps handling them.
- Central logic: all related handling lives in one place.
Events That Don't Bubble
Delegation relies on bubbling — but a few events don't bubble. For these, either attach listeners directly or use the bubbling alternative.
| Non-bubbling event | Fires when | Bubbling alternative |
|---|---|---|
focus | An element gains focus | focusin |
blur | An element loses focus | focusout |
mouseenter | Pointer enters the element | mouseover |
mouseleave | Pointer leaves the element | mouseout |
// Delegate focus handling across a whole form using focusin (it bubbles)
document.querySelector('form').addEventListener('focusin', (event) => {
if (event.target.matches('input')) event.target.classList.add('is-focused');
});
document.querySelector('form').addEventListener('focusout', (event) => {
if (event.target.matches('input')) event.target.classList.remove('is-focused');
});
⚠️ The alternatives behave slightly differently
mouseenter/mouseleave fire only for the element itself. mouseover/mouseout also fire as the pointer crosses into and out of child elements, so you may get more events than expected. Choose based on whether you care about children.
Hands-on: Delegated To-Do List
🏋️ One listener, three actions, dynamic items
Objective: Handle add, complete, and delete for a growing list using a single delegated listener — proving delegation works for elements created at runtime.
Starter markup
<form id="add-form">
<input id="new-task" placeholder="New task…" />
<button>Add</button>
</form>
<ul id="tasks"></ul>
Your task
- On form submit, add an
<li>containing the task text, a "Done" button, and a "Delete" button. - Add one click listener on
#tasks. - In it, use
closest()andmatches()to toggle completion or remove the item. - Confirm newly added tasks work without any extra listeners.
💡 Hint
Give the action buttons classes like .done and .del. In the delegated handler, first const li = event.target.closest('li'); return early if there isn't one. Then branch on event.target.matches('.done') vs .del.
✅ Sample solution
const form = document.getElementById('add-form');
const input = document.getElementById('new-task');
const list = document.getElementById('tasks');
// Add a task
form.addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
const li = document.createElement('li');
li.innerHTML =
`<span class="label">${text}</span> ` +
`<button class="done">Done</button> ` +
`<button class="del">Delete</button>`;
list.appendChild(li);
input.value = '';
input.focus();
});
// ONE delegated listener handles every current AND future item
list.addEventListener('click', (event) => {
const li = event.target.closest('li');
if (!li) return;
if (event.target.matches('.done')) {
li.classList.toggle('completed');
} else if (event.target.matches('.del')) {
li.remove();
}
});
The delete and done buttons work on tasks created after the listener was set up — no re-binding needed. That is the whole payoff of delegation.
Best Practices
| ✅ Do | ❌ Don't |
|---|---|
| Delegate on a stable parent for lists and grids | Attach a listener to every row you render |
Use event.target.closest(selector) then guard for null | Assume event.target is the exact element you expect |
Compare event.target when you need "was the parent itself clicked?" | Reach for stopPropagation() as a first resort |
Use focusin/mouseover to delegate non-bubbling events | Try to delegate focus/mouseenter (they don't bubble) |
🎯 Quick Quiz
Question 1: By default, in which phase do addEventListener handlers run?
Question 2: Why does event delegation work for elements added after the listener is attached?
Question 3: You want to delegate focus styling across a form, but focus doesn't bubble. What do you use?
Summary & Quiz
🎉 Key Takeaways
- Events flow in three phases: capturing → target → bubbling.
- Listeners run in the bubbling phase by default; pass
true/{ capture: true }for capturing. stopPropagation()halts the journey;preventDefault()cancels the default action — they're independent.- Delegation uses one parent listener plus
event.target.closest()to handle many children, including dynamic ones. - A few events (
focus,blur,mouseenter,mouseleave) don't bubble — usefocusin/focusout/mouseover/mouseout.
📚 Further Reading
- MDN — Event bubbling & delegation
- MDN — Element.closest()
- javascript.info — Event delegation
- MDN — Event.stopPropagation()
🚀 What's Next?
Forms are where events, delegation, and validation all meet. Next we focus on form events specifically — submit, input, and change — and how to validate and process a form entirely in JavaScript.
🎉 Excellent!
You've mastered how events travel and how to ride the wave with delegation. Onward to forms.