🌊 Event Propagation and Delegation
When you click a button nested inside a card inside a list, the click doesn't happen only on the button — it ripples through every ancestor. Understanding that ripple unlocks one of the most useful patterns in front-end development: handling events for hundreds of elements with a single listener.
Week 2 · Day 3 (Wednesday: Events and Event Handling) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Describe the three phases of event flow: capture, target, and bubble
- Predict the exact order handlers fire on nested elements
- Register listeners for the capture phase and explain when that's useful
- Control flow with
stopPropagation()andstopImmediatePropagation() - Implement event delegation using
event.target,matches(), andclosest() - Explain why delegation is the right tool for dynamic and large lists
Estimated Time: 60 minutes
Practice: Build a delegated to-do list that keeps working as items are added.
In This Lesson
Events Travel Through the DOM
An event doesn't fire in isolation on the element you interacted with. Because every element is nested inside others — a button inside a <div> inside <body> inside the document — the browser gives every ancestor a chance to respond too. The event propagates.
Picture dropping a stone into a pond. The splash happens at one point (the element you clicked), but ripples spread outward through the surrounding water (the ancestor elements). The browser formalizes this into a predictable, three-phase journey.
The event dives down from the window to the target (the capture phase), reaches the target, then bubbles back up to the window (the bubble phase). By default, the listeners you write run during that bubbling trip up.
The Three Phases
1. Capture phase (downward)
The event starts at the window and travels down to the target, visiting each ancestor. Listeners registered with { capture: true } (or the older true third argument) fire here.
2. Target phase
The event reaches the element where it originated. Both capture and bubble listeners on the target itself fire in this moment.
3. Bubble phase (upward)
The event travels back up to the window, revisiting each ancestor. This is the default — the phase your normal listeners run in — and it's exactly what makes delegation possible.
const div = document.querySelector('.card');
const button = div.querySelector('button');
// Capture-phase listener (third arg true, or { capture: true })
div.addEventListener('click', () => console.log('div CAPTURE'), true);
// Target listener
button.addEventListener('click', () => console.log('button TARGET'));
// Bubble-phase listener (the default)
div.addEventListener('click', () => console.log('div BUBBLE'));
// Clicking the button logs, in order:
// div CAPTURE ← on the way down
// button TARGET ← at the target
// div BUBBLE ← on the way back up
💡 You'll almost always use bubbling
Capture is occasionally handy — for intercepting an event before inner handlers see it, or for focus events that don't bubble. But 95% of the time you want the default bubbling behavior, which is what delegation relies on.
Controlling Propagation
Sometimes you need to stop the ripple. JavaScript gives you two related methods — plus the preventDefault() from the last lesson, which is not about propagation at all.
stopPropagation()
Stops the event from continuing to the next element in the flow. Handlers already on the current element still run; ancestors never hear about it.
const inner = document.querySelector('.inner');
const outer = document.querySelector('.outer');
inner.addEventListener('click', (e) => {
console.log('Inner clicked');
e.stopPropagation(); // the event stops here
});
outer.addEventListener('click', () => {
console.log('Outer clicked'); // never runs when .inner is clicked
});
A classic use is a modal dialog: click the dark overlay to close it, but a click inside the dialog shouldn't bubble up to that overlay handler.
overlay.addEventListener('click', () => closeModal());
dialog.addEventListener('click', (e) => {
e.stopPropagation(); // clicks inside the dialog don't reach the overlay
});
stopImmediatePropagation()
Does everything stopPropagation() does and prevents any other listeners on the same element from running.
button.addEventListener('click', (e) => {
console.log('Handler 1');
e.stopImmediatePropagation();
});
button.addEventListener('click', () => {
console.log('Handler 2'); // never runs — same element, blocked
});
⚠️ Stop propagation sparingly
stopPropagation() is a blunt instrument. Because delegation depends on events reaching a parent, an over-eager stopPropagation() on a child can silently break a delegated handler somewhere up the tree. Reach for it only when you truly need to isolate an interaction — like the modal above.
Event Delegation
Here's the payoff. Because events bubble, a listener on a parent element will hear clicks from all of its descendants. Event delegation means: instead of attaching a listener to every child, attach one listener to the parent and figure out which child was actually clicked using the event object.
The naive way vs. the delegated way
// ❌ Without delegation: one listener per button.
// Breaks for buttons added later, and costs memory at scale.
document.querySelectorAll('.item-button').forEach((btn) => {
btn.addEventListener('click', handleItemClick);
});
// ✅ With delegation: ONE listener on the container.
const list = document.querySelector('.items');
list.addEventListener('click', (e) => {
// Only act if the click landed on (or inside) an item button
if (e.target.closest('.item-button')) {
handleItemClick(e);
}
});
The delegated version is shorter, uses one listener no matter how many items exist, and — the killer feature — automatically handles items added to the list after the listener was set up. New children bubble to the same parent.
target, matches & closest
Delegation lives or dies on correctly identifying what was clicked. Three tools do the job.
| Tool | What it answers |
|---|---|
event.target | The exact element the event started on |
el.matches('.sel') | "Does this element itself match this selector?" → true/false |
el.closest('.sel') | Walks up from the element to find the nearest ancestor (or itself) matching the selector |
closest() is the hero of delegation. If a user clicks an icon or a text span inside a button, event.target is that inner element — but event.target.closest('.item-button') still finds the button.
const list = document.querySelector('.todo-list');
list.addEventListener('click', (e) => {
// Which row was interacted with?
const item = e.target.closest('.todo-item');
if (!item) return; // clicked empty space — ignore
// Which control inside the row?
if (e.target.matches('.delete-btn')) {
item.remove();
} else if (e.target.matches('.toggle-btn')) {
item.classList.toggle('done');
}
});
✅ The delegation recipe
- Attach one listener to a stable parent container.
- Use
e.target.closest('.row')to find the logical item, and bail early if there isn't one. - Use
e.target.matches('.specific-button')to branch on which control was hit.
Data attributes make branching clean
// HTML: <button data-action="delete">Delete</button>
list.addEventListener('click', (e) => {
const button = e.target.closest('[data-action]');
if (!button) return;
const row = button.closest('.todo-item');
switch (button.dataset.action) {
case 'delete': deleteItem(row.dataset.id); break;
case 'edit': editItem(row.dataset.id); break;
case 'toggle': toggleItem(row.dataset.id); break;
}
});
Why Delegation Wins
One listener, many elements
A list of 1,000 rows with individual listeners means 1,000 handler registrations sitting in memory. Delegation replaces all of them with one. Less memory, faster setup, and no bookkeeping.
| Direct listeners | Delegation | |
|---|---|---|
| Listeners in memory | One per element | Exactly one |
| New/dynamic elements | Must re-attach manually | Work automatically |
| Setup cost | Grows with element count | Constant |
| Best for | A few unique, static elements | Lists, tables, generated content |
const container = document.querySelector('#feed');
// Set the listener ONCE, up front.
container.addEventListener('click', (e) => {
if (e.target.matches('.like-button')) {
toggleLike(e.target.closest('.post').dataset.id);
}
});
// Later, add posts dynamically — no new listeners needed.
container.insertAdjacentHTML('beforeend', renderPost(newPost));
// The like button in that fresh post already works, because its
// click bubbles up to the container's single listener. ✨
💡 Delegate at the right level
Attach the listener to the nearest stable container that holds all the items — a <ul>, a table's <tbody>, a feed <div>. Delegating everything to document works but is wasteful; delegating too low loses the benefit. Pick the container that owns the collection.
Practice & Quiz
🏋️ Exercise 1: Predict the order
Goal: Given the code below, write down exactly what the console prints when the button is clicked — before you run it.
outer.addEventListener('click', () => console.log('A'), true); // capture
outer.addEventListener('click', () => console.log('B')); // bubble
button.addEventListener('click', () => console.log('C')); // target
💡 Hint
Capture fires on the way down, target in the middle, bubble on the way up. The button is a child of outer.
✅ Solution
// A ← outer's capture listener runs first (downward)
// C ← the target (button) runs
// B ← outer's bubble listener runs last (upward)
Order: A, C, B.
🏋️ Exercise 2: Delegated to-do list
Goal: With a <ul id="todos"> whose items look like <li class="todo"><span>Task</span> <button class="del">✕</button></li>, use a single listener so that clicking a task toggles a done class and clicking its ✕ removes the row — and it must keep working for tasks added later.
💡 Hint
Put one click listener on #todos. Use e.target.closest('.todo') for the row, then check whether e.target.matches('.del') to decide delete vs. toggle.
✅ Solution
const todos = document.querySelector('#todos');
todos.addEventListener('click', (e) => {
const row = e.target.closest('.todo');
if (!row) return; // clicked empty space
if (e.target.matches('.del')) {
row.remove(); // delete
} else {
row.classList.toggle('done'); // toggle complete
}
});
// Any <li class="todo"> appended later is handled automatically —
// its clicks bubble up to this one listener.
🎯 Quick Quiz
Question 1: In which phase do your listeners run by default?
Question 2: Why does event delegation automatically handle elements added after the listener was set up?
Question 3: A user clicks a <span> inside a .card. Which reliably gives you the card in a delegated handler?
Best Practices & Pitfalls
✅ Do
- Delegate for lists, tables, and any dynamically generated content
- Use
closest()to survive clicks on nested inner elements - Bail out early (
if (!item) return;) when the click isn't on a relevant element - Delegate at the nearest stable container that owns the collection
- Use
data-*attributes to drive clean, switch-based branching
❌ Don't
- Attach a separate listener to every item when one on the parent will do
- Call
stopPropagation()reflexively — it can break delegation upstream - Rely on
event.targetalone when the target may be a nested child - Delegate everything to
documentwhen a tighter container is available
⚠️ Some events don't bubble
focus, blur, mouseenter, and mouseleave do not bubble, so plain delegation won't catch them. Use their bubbling cousins — focusin/focusout and mouseover/mouseout — or register the listener in the capture phase.
Summary
🎉 Key Takeaways
- Events travel in three phases: capture (down) → target → bubble (up)
- Your listeners run in the bubble phase unless you opt into capture
stopPropagation()halts the ripple;stopImmediatePropagation()also blocks same-element handlers- Delegation = one listener on a parent, identifying the child via the event object
closest()finds the logical item even when a nested child was clicked;matches()branches on the exact control- Delegation uses less memory and handles dynamically added elements for free
📚 Additional Resources
🚀 What's Next?
You now understand how events flow and how to handle them efficiently. Next we focus that knowledge on the most interactive part of any app: Form Events and Validation — capturing input, validating in real time, and submitting cleanly.
🎉 Well done!
Delegation is a pattern you'll use in nearly every real project. You've just leveled up.