Skip to main content

🌊 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 from preventDefault()
  • 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.

Capturing and bubbling phases The event descends from document to outer to inner during the capturing phase, hits the target, then rises back through inner, outer, and document during the bubbling phase. document #outer #middle target 1. capturing ↓ 3. bubbling ↑
Figure 1 — The event descends (capturing), reaches the target, then ascends (bubbling). Listeners can run on either leg.

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.

flowchart TD P[Parent container
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 eventFires whenBubbling alternative
focusAn element gains focusfocusin
blurAn element loses focusfocusout
mouseenterPointer enters the elementmouseover
mouseleavePointer leaves the elementmouseout
// 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

  1. On form submit, add an <li> containing the task text, a "Done" button, and a "Delete" button.
  2. Add one click listener on #tasks.
  3. In it, use closest() and matches() to toggle completion or remove the item.
  4. 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 gridsAttach a listener to every row you render
Use event.target.closest(selector) then guard for nullAssume 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 eventsTry 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 — use focusin/focusout/mouseover/mouseout.

📚 Further Reading

🚀 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.