Event Bubbling, Capturing and Delegation

Understand how events travel through the DOM, use event.target and currentTarget, stop propagation when needed, and handle many elements with one delegated listener.

Lesson 37 of 56Intermediate16 min read

When you click a button inside a card inside a list, which element did you click? All of them, in a sense. A click doesn't just happen on the button: the event travels up through every ancestor, from the button to the card, to the list, to the <body>, all the way to the document. This journey is called event bubbling, and once you understand it, a whole class of event-handling problems becomes easy.

The most useful technique it enables is event delegation. Instead of attaching a separate listener to each of a hundred list items, you attach one listener to the list and check which item the click came from. It uses less memory, and, crucially, it keeps working for items you add later with JavaScript, which individual listeners don't.

In this lesson you'll learn the three phases of an event, the difference between event.target and event.currentTarget, when to stop an event spreading, how delegation works with closest, and useful listener options like once and passive. You learned the basics of listeners in JavaScript events.

How events travel

An event goes through three phases:

PhaseDirectionListeners that run
1. CapturingFrom window down to the target's parentThose added with { capture: true }
2. TargetAt the element that was actually clickedListeners on the target itself
3. BubblingFrom the target's parent back up to windowNormal listeners (the default)

Almost all listeners use the bubbling phase. Most events bubble, including click, input, keydown and submit. A few don't, such as focus, blur, mouseenter and mouseleave; use focusin, focusout, mouseover and mouseout when you need bubbling versions.

target vs currentTarget

PropertyIs…
event.targetThe element where the event started: the innermost element clicked
event.currentTargetThe element whose listener is running right now

If a list has a listener and you click a <span> inside an <li>, target is the <span> and currentTarget is the list. That's why delegation code usually does event.target.closest("li"): it climbs from wherever the click landed to the item you care about.

Event delegation

JavaScript
list.addEventListener("click", (event) => {
  const item = event.target.closest("li");
  if (!item || !list.contains(item)) return; // click wasn't on an item
  item.classList.toggle("done");
});
  • One listener handles every item, however many there are.
  • Items added later work automatically, because the listener is on the parent.
  • Use data-* attributes to tell actions apart: <button data-action="delete">, then check event.target.closest("[data-action]")?.dataset.action.
  • matches(selector) checks whether an element fits a selector, which is useful when the target itself is what you want.

Stopping and preventing

MethodDoes
event.preventDefault()Cancels the browser's default action: following a link, submitting a form, ticking a box
event.stopPropagation()Stops the event travelling further up (or down) the tree
event.stopImmediatePropagation()Also stops other listeners on the same element

These are different things: preventDefault doesn't stop bubbling, and stopPropagation doesn't stop the default action. Use stopPropagation sparingly. It can break other code that relies on events reaching the document, such as analytics, or "click outside to close" menus.

Listener options

JavaScript
btn.addEventListener("click", handler, { once: true });      // removes itself after one run
window.addEventListener("scroll", handler, { passive: true }); // promises not to call preventDefault
el.addEventListener("click", handler, { capture: true });   // runs in the capture phase
const controller = new AbortController();
el.addEventListener("click", handler, { signal: controller.signal });
controller.abort(); // removes every listener that uses this signal

passive: true lets the browser scroll smoothly without waiting for your code. signal is a tidy way to remove several listeners at once.

JavaScript Event Delegation Syntax

Events and delegation

parent.addEventListener("click", (e) => {
  const btn = e.target.closest("[data-action]");
  if (!btn) return;
  if (btn.dataset.action === "delete") btn.closest("li").remove();
});

link.addEventListener("click", (e) => e.preventDefault());
inner.addEventListener("click", (e) => e.stopPropagation());
el.addEventListener("click", fn, { once: true });

JavaScript Event Delegation Summary

  • Events capture down to the target, then bubble back up through every ancestor.
  • event.target is where the event started; event.currentTarget is where the listener is.
  • Event delegation puts one listener on a parent and uses target.closest() to find the item.
  • Delegated listeners work for elements added later.
  • preventDefault cancels default actions; stopPropagation stops bubbling, so use it sparingly.

JavaScript Event Delegation Examples

Each example runs in the page. Edit the code and press Run to see what changes.

Example 1

Watching an event bubble

Click the button, then click the white area of the card.

for (const id of ["btn", "card", "outer"]) {
  document.getElementById(id).addEventListener("click", (e) => {
    console.log(`${id} heard it | target: ${e.target.id} | currentTarget: ${e.currentTarget.id}`);
  });
}

document.getElementById("outer").addEventListener("click", () => {
  console.log("outer (capture phase) runs first");
}, { capture: true });
ResultEdit the code, then press Run or Ctrl+Enter
Line-by-line explanation
  1. Lines 1 to 5Listeners on three nested elements log who heard the click.
  2. Line 3target stays the same, while currentTarget changes as the event bubbles up.
  3. Lines 7 to 9A capturing listener on the outer box runs before any of the bubbling ones.
Example 2

One listener for a whole list

Add items, then tick or delete them. New items work without new listeners.

const tasks = document.querySelector("#tasks");
let n = 1;

tasks.addEventListener("click", (e) => {
  const btn = e.target.closest("[data-action]");
  if (!btn) return;
  const item = btn.closest("li");
  if (btn.dataset.action === "done") item.classList.toggle("is-done");
  if (btn.dataset.action === "delete") item.remove();
});

document.querySelector("#add").addEventListener("click", () => {
  const li = tasks.firstElementChild?.cloneNode(true) ?? document.createElement("li");
  li.classList.remove("is-done");
  li.querySelector("span").textContent = `New task ${++n}`;
  tasks.append(li);
});
ResultEdit the code, then press Run or Ctrl+Enter
Line-by-line explanation
  1. Line 4A single listener on the list.
  2. Lines 5 to 6Ignore clicks that weren't on an action button.
  3. Line 7Find the list item that the button belongs to.
  4. Lines 8 to 9Decide what to do from the button's data-action.
  5. Lines 12 to 17Items added later are handled by the same listener.
Example 3

preventDefault, stopPropagation and click outside

document.querySelector("#link").addEventListener("click", (e) => {
  e.preventDefault();
  console.log("Navigation cancelled");
});

const menu = document.querySelector("#menu");
const state = document.querySelector("#state");

document.addEventListener("click", (e) => {
  state.textContent = menu.contains(e.target) ? "Menu is open" : "Clicked outside: menu closed";
});

document.querySelector("#inner").addEventListener("click", () => {
  console.log("Inner button clicked, and the event still bubbles to the document");
}, { once: true });
ResultEdit the code, then press Run or Ctrl+Enter
Line-by-line explanation
  1. Line 2The link's default action, navigating, is cancelled.
  2. Lines 9 to 11A document listener checks whether the click came from inside the menu.
  3. Lines 13 to 15once removes this listener after the first click.

Common JavaScript Event Delegation Mistakes

Adding listeners to every item in a loop

Don't
document.querySelectorAll("li").forEach(li => li.addEventListener("click", select));
// items added later never get the listener
Do
list.addEventListener("click", (e) => {
  const li = e.target.closest("li");
  if (li) select(li);
});

Using target when the click lands on a child

Don't
list.addEventListener("click", (e) => {
  e.target.classList.toggle("done"); // may be the <span> or <img> inside the li
});
Do
list.addEventListener("click", (e) => {
  e.target.closest("li")?.classList.toggle("done");
});

stopPropagation to fix a symptom

Don't
menuButton.addEventListener("click", (e) => { e.stopPropagation(); toggle(); });
// breaks other document-level listeners, like analytics or other menus
Do
document.addEventListener("click", (e) => {
  if (!menu.contains(e.target) && !menuButton.contains(e.target)) close();
});

JavaScript Event Delegation in Real Projects

Delegation in the wild

Email inboxes, data tables and kanban boards with hundreds of rows use one delegated listener per list. React attaches a single listener at the root of your app and delegates every event from there. Analytics scripts listen for clicks on the document and read data-track attributes from whatever was clicked. Dropdown menus use document-level listeners to close when you click outside.

JavaScript Event Delegation Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Find the list item that was clicked, even if the click landed on something inside it.

list.addEventListener("click", (event) => {
  const item = event.target.("li");
  console.log(item?.textContent);
});
Predict the outputmedium

A list ul has a click listener, and you click a <b> inside one of its <li> elements. What are e.target.tagName and e.currentTarget.tagName?

ul.addEventListener("click", (e) => console.log(e.target.tagName, e.currentTarget.tagName));
Multiple choiceeasy

What is event bubbling?

Multiple choicemedium

What's the main advantage of event delegation?

Fix the bugmedium

Clicking any Remove button, including on rows added later, should remove its own row. Fix it with delegation.

document.querySelectorAll(".rm").forEach((btn) => {
  btn.addEventListener("click", (e) => e.currentTarget.parentElement.parentElement.remove());
});
const extra = document.createElement("li");
extra.innerHTML = 'Row 2 <button class="rm">Remove</button>';
document.querySelector("#rows").append(extra);
ResultEdit the code, then press Run or Ctrl+Enter
Checks
  • The added row's button works
  • The list itself survives

JavaScript Event Delegation Quiz

5 questions to check what you learned.

  1. Which method stops a link from navigating?

    • stopPropagation()
    • preventDefault()
    • return false in addEventListener
    • removeEventListener()
  2. Which of these events does NOT bubble?

    • focus
    • click
    • input
    • keydown
  3. What does { once: true } do?

    • Runs the listener in the capture phase
    • Prevents the default action
    • Stops bubbling
    • Removes the listener automatically after it runs once
  4. How do you check whether a click happened outside a menu?

    • !menu.contains(event.target)
    • event.target === document
    • event.outside
    • menu.isClicked === false
  5. Which phase runs listeners added with { capture: true }?

    • Bubbling
    • Only the target phase
    • Capturing, on the way down before the target
    • After all bubbling listeners

JavaScript Event Delegation Challenge

A delegated colour picker

#palette contains buttons with data-colour attributes. Using one listener on #palette:

  1. When a swatch button is clicked (even if the click lands on its inner <span>), set #preview's style.background to the button's data-colour
  2. Give the clicked button the class active and remove active from all the others
  3. Ignore clicks on the palette's gaps (not on a button)
const palette = document.querySelector("#palette");
const preview = document.querySelector("#preview");
ResultEdit the code, then press Run or Ctrl+Enter
Checks
  • Clicking a span inside a swatch works
  • Only one swatch is active
  • Gap clicks are ignored
  • A single listener on the palette