Skip to main content

🌊 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() and stopImmediatePropagation()
  • Implement event delegation using event.target, matches(), and closest()
  • 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.

graph TD W[window] --> D[document] D --> H[html] H --> B[body] B --> C[div.card] C --> BTN[button — the target] BTN -.->|↑ bubble back up| W

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

Event flow: the capture phase travels down from window to the target, then the bubble phase travels back up window / document body div.card button (target) 1. CAPTURE ↓ 3. BUBBLE ↑
The event captures downward to the target, fires at the target, then bubbles back upward. Your listeners run on the way up unless you opt into capture.

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.

graph TD P[Parent list — ONE listener] --> C1[Item 1] P --> C2[Item 2] P --> C3[Item 3] P --> C4[Item 4] P --> CN[... Item N]

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.

ToolWhat it answers
event.targetThe 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

  1. Attach one listener to a stable parent container.
  2. Use e.target.closest('.row') to find the logical item, and bail early if there isn't one.
  3. 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 listenersDelegation
Listeners in memoryOne per elementExactly one
New/dynamic elementsMust re-attach manuallyWork automatically
Setup costGrows with element countConstant
Best forA few unique, static elementsLists, 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.target alone when the target may be a nested child
  • Delegate everything to document when 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.