Event propagation and delegation

JavaScript · Practical · 10 min

Delegation keeps dynamic lists and tables fast and simple to wire up.

When an event fires on an element, it travels through the DOM in three phases:

  • Capture: from window down to the target's parent.
  • Target: at the element where it happened.
  • Bubble: back up from the target to window.

Listeners run in the bubble phase by default; pass { capture: true } to run during capture instead. Most events bubble; some don't, notably focus, blur, mouseenter and mouseleave (use focusin/focusout or pointerover when you need bubbling versions).

Inside a handler:

  • event.target is the element where the event originated (maybe a deeply nested <span>).
  • event.currentTarget is the element whose listener is running.

event.stopPropagation() stops the event travelling further. Use it sparingly — it breaks other code (analytics, close-on-outside-click menus) that relies on bubbling. preventDefault() is unrelated: it cancels the default action but lets the event continue.

Event delegation uses bubbling to handle events for many elements with a single listener on a common ancestor. Combined with element.closest(selector), which walks up from the target to the nearest matching ancestor, it looks like this:

list.addEventListener("click", (e) => {
  const button = e.target.closest("button[data-action]");
  if (!button || !list.contains(button)) return;
  ...
});

Benefits: one listener instead of hundreds, and elements added later work automatically with no extra wiring. Put the action and IDs in data-* attributes so the handler knows what to do.

Example

// Assumes: <ul id="todos"> with items like
// <li data-id="1">Task <button data-action="done">✓</button>
//                      <button data-action="delete">✕</button></li>
const list = document.querySelector("#todos");

list.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-action]");
  if (!button || !list.contains(button)) return;   // click elsewhere

  const item = button.closest("li");
  console.log("listener on:", event.currentTarget.id); // "todos"
  if (button.dataset.action === "delete") item.remove();
  if (button.dataset.action === "done") item.classList.toggle("done");
});

// Items added later need no new listeners:
const li = document.createElement("li");
li.dataset.id = "99";
li.innerHTML = 'New <button data-action="delete">✕</button>';
list.append(li);

Remember

  • Events capture down, hit the target, then bubble up
  • target is where it started; currentTarget is where the listener is
  • Delegate with one ancestor listener + closest() for dynamic content

Watch out for

  • Calling stopPropagation() by habit, which silently breaks other listeners higher up.

Check yourself

  1. A click on a <span> inside a <button> bubbles to a listener on the <ul>. What are event.target and event.currentTarget?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: developer.mozilla.org/en-US/docs/Web/API/Event/currentTarget

Builds on

Unlocks

Nothing builds on this yet.