Home Module 11 Event Bubbling & Capturing

1. Introduction

When you click a button inside a div inside a section, which element "receives" the click? The answer is: all of them. Events do not stop at the element you clicked — they travel through the DOM in a predictable path. Understanding this journey is essential for writing correct event-driven code and is the foundation of the powerful event delegation pattern you will learn in Lesson 5.

2. Theory

2.1 The three phases

Every DOM event travels in three phases:

  1. Capture phase — the event travels down from document to the target element, passing through every ancestor.
  2. Target phase — the event reaches the element that was directly interacted with (event.target).
  3. Bubble phase — the event travels back up from the target to document, passing through every ancestor again.
// DOM structure:
// document
//   <body>
//     <section>
//       <div>
//         <button>  <-- clicked here

// Capture phase (down): document → body → section → div → button
// Target phase:                                             button
// Bubble phase (up):   document ← body ← section ← div ← button

2.2 Bubbling — the default

By default, addEventListener fires in the bubble phase. This means if you click a button, its click handler fires first, then the div's click handler, then the section's, all the way up to document.

document.querySelector('div').addEventListener('click', () => {
  console.log('DIV clicked');
});

document.querySelector('button').addEventListener('click', () => {
  console.log('BUTTON clicked');
});

// User clicks the button — console output:
// "BUTTON clicked"
// "DIV clicked"

2.3 event.target vs event.currentTarget

document.querySelector('div').addEventListener('click', function(e) {
  console.log(e.target);        // the element actually clicked (button)
  console.log(e.currentTarget); // the element THIS listener is on (div)
  console.log(this);            // same as e.currentTarget in regular functions
});

e.target never changes as the event bubbles. e.currentTarget updates at each element the listener fires on.

2.4 stopPropagation()

Stops the event from traveling further. Handlers on the current element still run, but no ancestor handlers fire.

document.querySelector('button').addEventListener('click', e => {
  e.stopPropagation(); // event stops here — div handler won't fire
  console.log('Button clicked');
});

document.querySelector('div').addEventListener('click', () => {
  console.log('This will NOT run when button is clicked');
});

2.5 stopImmediatePropagation()

Stops propagation AND prevents any other listeners on the same element from firing.

btn.addEventListener('click', e => {
  e.stopImmediatePropagation();
  console.log('Handler 1 — runs');
});

btn.addEventListener('click', () => {
  console.log('Handler 2 — NEVER runs (same element, stopped immediately)');
});

// Use sparingly — makes code very hard to debug

2.6 The capture phase

Pass true (or { capture: true }) as the third argument to listen in the capture phase instead of bubble phase.

// Fires BEFORE the button's own handler (capture fires on the way down)
document.querySelector('div').addEventListener('click', () => {
  console.log('DIV capture — fires first');
}, true); // <-- capture phase

document.querySelector('button').addEventListener('click', () => {
  console.log('BUTTON bubble — fires second');
});

// User clicks button — output:
// "DIV capture — fires first"
// "BUTTON bubble — fires second"

Capture listeners are rare in practice. The most common use case is intercepting events before they reach their target — for example, a global error handler or analytics layer.

2.7 Which events bubble?

Most events bubble, but a few do not:

// These DO bubble:
// click, dblclick, mousedown, mouseup, keydown, keyup
// input, change, submit, reset
// mouseover, mouseout (bubble — unlike mouseenter/mouseleave)

// These do NOT bubble:
// focus, blur (use focusin/focusout instead — those DO bubble)
// mouseenter, mouseleave
// load, unload, DOMContentLoaded

// Check programmatically:
element.addEventListener('focus', e => {
  console.log(e.bubbles); // false — focus does not bubble
});

2.8 Practical: closing a modal on outside click

// Classic pattern — close modal when clicking outside it
const modal    = document.querySelector('.modal');
const overlay  = document.querySelector('.overlay');

overlay.addEventListener('click', e => {
  // If the click target IS the overlay (not the modal inside it), close
  if (e.target === overlay) {
    modal.classList.remove('open');
  }
});

// Alternative with stopPropagation on the modal itself:
modal.addEventListener('click', e => {
  e.stopPropagation(); // don't let clicks inside modal reach overlay
});
overlay.addEventListener('click', () => {
  modal.classList.remove('open');
});

2.9 Bubbling order with multiple listeners

// Multiple listeners on the same element — fire in registration order
btn.addEventListener('click', () => console.log('First'));
btn.addEventListener('click', () => console.log('Second'));
btn.addEventListener('click', () => console.log('Third'));
// Output: First, Second, Third (always in order added)

3. Real World Example

// Accordion component — click header to expand/collapse panel
// Clicking any element inside the header still works (icon, text span)

const accordion = document.querySelector('.accordion');

accordion.addEventListener('click', function(e) {
  // Walk up from the clicked element to find the header
  const header = e.target.closest('.accordion-header');
  if (!header) return; // clicked something outside a header

  const panel  = header.nextElementSibling; // the content panel
  const isOpen = panel.classList.contains('open');

  // Close all open panels
  accordion.querySelectorAll('.accordion-panel.open').forEach(p => {
    p.classList.remove('open');
  });
  accordion.querySelectorAll('.accordion-header.active').forEach(h => {
    h.classList.remove('active');
  });

  // Open clicked one (unless it was already open)
  if (!isOpen) {
    panel.classList.add('open');
    header.classList.add('active');
  }
});

// One listener on the parent handles ALL headers — that's event delegation

4. Code Example

<div id="outer" style="padding:40px; background:#f0f0f0">
  OUTER
  <div id="middle" style="padding:30px; background:#c0c0c0">
    MIDDLE
    <button id="inner">INNER BUTTON</button>
  </div>
</div>
<label><input type="checkbox" id="stop"> Stop propagation on INNER</label>
<ul id="log"></ul>

<script>
  const log  = document.querySelector('#log');
  const stop = document.querySelector('#stop');

  function addLog(msg) {
    const li = document.createElement('li');
    li.textContent = msg;
    log.prepend(li);
  }

  document.querySelector('#outer').addEventListener('click', e => {
    addLog(`OUTER bubble — target: ${e.target.id}, current: outer`);
  });

  document.querySelector('#middle').addEventListener('click', e => {
    addLog(`MIDDLE bubble — target: ${e.target.id}, current: middle`);
  });

  document.querySelector('#inner').addEventListener('click', e => {
    if (stop.checked) e.stopPropagation();
    addLog(`INNER bubble — target: ${e.target.id}, current: inner`);
  });

  // Capture phase listeners (fire before bubble)
  document.querySelector('#outer').addEventListener('click', e => {
    addLog(`OUTER capture — target: ${e.target.id}`);
  }, true);

  document.querySelector('#middle').addEventListener('click', e => {
    addLog(`MIDDLE capture — target: ${e.target.id}`);
  }, true);
</script>

5. Code Breakdown

Capture listeners (true as third arg)

The outer and middle listeners registered with true fire before the inner button's own listener — in document-to-target order. They appear first in the log even though they are attached to parent elements.

Bubble listeners (default)

After the inner button's handler fires, the event bubbles outward. Middle fires, then outer. The log shows events from inner outward.

stopPropagation checkbox

When checked, calling e.stopPropagation() in the inner handler prevents the event from reaching middle and outer in the bubble phase. The capture phase has already completed, so capture handlers still ran.

e.target stays constant

Notice that e.target.id is always "inner" (the button that was clicked) in every handler, while the description "current" changes — illustrating the difference between target and currentTarget.

6. Common Mistakes

Mistake 1 — Overusing stopPropagation

// Bad — stops ALL bubbling, breaks other listeners that rely on it
btn.addEventListener('click', e => {
  e.stopPropagation(); // blocks analytics, accessibility tools, etc.
  doThing();
});

// Better — check e.target or e.currentTarget instead of stopping propagation
parent.addEventListener('click', e => {
  if (e.target !== e.currentTarget) return; // only react to direct clicks
  doThing();
});

Mistake 2 — Confusing target and currentTarget

// Bug — uses e.target when e.currentTarget is needed
list.addEventListener('click', e => {
  e.target.classList.add('selected'); // works IF user clicks the li directly
  // BUT if user clicks a <span> inside the li, e.target is the span!
});

// Fix — use closest() to find the intended element
list.addEventListener('click', e => {
  const item = e.target.closest('li');
  if (item) item.classList.add('selected');
});

Mistake 3 — Expecting focus/blur to bubble

// Bug — focus does not bubble, so this never fires
form.addEventListener('focus', e => {
  console.log('A field was focused'); // never runs
});

// Fix — use focusin which DOES bubble
form.addEventListener('focusin', e => {
  console.log('A field was focused:', e.target); // works!
});

Mistake 4 — stopPropagation breaking event delegation

// If a child component stops propagation...
innerComponent.addEventListener('click', e => e.stopPropagation());

// ...then parent-level event delegation breaks
document.addEventListener('click', e => {
  // This never sees clicks on innerComponent or its children
});

7. Best Practices

  1. Understand bubbling before writing event code — unexpected handler firing is almost always a bubbling issue.
  2. Use e.target.closest() instead of stopPropagation to find the intended element.
  3. Use focusin/focusout (bubble) instead of focus/blur (no bubble) when listening on a container.
  4. Avoid stopPropagation in library/shared code — it will silently break things for other consumers.
  5. Use stopImmediatePropagation only as a last resort — it makes debugging extremely difficult.
  6. Capture phase listeners are rare — only use them when you genuinely need to intercept an event before it reaches the target.

8. Practice Exercise

  1. Create nested divs (3 levels deep) each with a click listener. Click the innermost and observe bubbling in the console. Then add stopPropagation at level 2 and observe the difference.
  2. Build a modal: clicking the dark overlay closes it, clicking inside the modal does not. Use either e.target === overlay check or stopPropagation on the modal.
  3. Attach a focusin listener to a form element and log which input was focused. Confirm that focus (without "in") does NOT bubble to the form.

9. Assignment

Build a "Bubble Visualiser" page.

  1. Create five nested divs, each a different background colour and clearly labelled (Level 1 through Level 5).
  2. Attach bubble-phase click listeners to all five levels and a capture-phase listener on Level 1.
  3. Display a live event log showing each handler that fires, in order, with the phase name (capture/bubble), the currentTarget level, and e.target.
  4. Add a checkbox "Stop at Level 3" that calls stopPropagation at Level 3's listener when checked.
  5. Add a "Clear Log" button.

Deliverable: One HTML file.

10. Interview Questions

  1. What is event bubbling?
    After an event fires on an element (the target), it propagates upward through each ancestor element in order until it reaches the document. Listeners on ancestor elements receive the event in this bubble phase by default.
  2. What is the difference between e.target and e.currentTarget?
    e.target is the element that originally triggered the event — it never changes as the event bubbles. e.currentTarget is the element that the currently-executing listener is attached to — it changes at each step of propagation.
  3. What does stopPropagation do?
    It stops the event from traveling further up (or down in capture phase) the DOM. Handlers on the current element still run, but ancestor (or descendant) handlers do not.
  4. What is the difference between stopPropagation and stopImmediatePropagation?
    stopPropagation prevents the event from reaching other elements. stopImmediatePropagation also prevents any remaining listeners on the same element from firing. It is a more aggressive stop.
  5. Why don't focus and blur bubble?
    By design — historically to avoid performance issues. Use focusin and focusout instead, which are equivalent but do bubble, allowing container-level focus monitoring.

11. Additional Resources

  • MDN — Event bubbling — full phase explanation with diagrams
  • MDN — Event.stopPropagation()
  • javascript.info — Bubbling and capturing — excellent interactive examples
  • MDN — Event.target vs Event.currentTarget