Tutorial · html-css-js · Published 2026-08-15 · 3 min read

Event delegation in JavaScript

Event delegation tutorial: use event bubbling to handle clicks on dynamic content with one listener instead of many.

The bubbling model

When a user clicks a button inside the page, the browser fires the click event on the deepest clicked element first and then walks the event up the ancestor chain, from the element through each parent until it reaches document. This upward phase is "bubbling". The reverse direction, from document down to the target, is the capture phase and runs first. For most handlers you only need bubbling.

Because bubbling exists, you can listen for a click once on a container and still catch clicks on every child inside it, including children that do not exist yet. This is the entire idea behind delegation.

Why delegation beats one listener per element

Attaching a separate listener to every item is the first instinct:

// The manual way that does not scale
const buttons = document.querySelectorAll("#todo button");
buttons.forEach((button) => {
  button.addEventListener("click", () => openItem());
});

It works while the list is static. The moment someone adds an item after the page loads, the query runs again or the new button is silent. JavaScript frameworks rebuilt around this do not solve the growth, they just defer it. Delegation moves the work to one listener that survives the list changing:

const list = document.querySelector("#todo");
list.addEventListener("click", (event) => {
  const button = event.target.closest("button.done");
  if (!button) return;
  openItem(button.dataset.id);
});

The pieces: event.target is the deepest element the user clicked, .closest() walks up from it to find the nearest matching ancestor, and the early return filters clicks on padding and wrappers. One listener handles a list of five or five thousand items, and items added later with innerHTML or a framework render are already covered.

The dynamic list recipe

Ordered steps for a list that loads from an API:

  1. Render the list into the container as usual.
  2. Attach exactly one click listener to the container, not to each row.
  3. In the handler, call event.target.closest("button.done") to prove the click came from a real action.
  4. Use the item id from a data-* attribute to do the work.
  5. When new rows arrive, insert them with normal rendering. No listener work needed, they are already covered.
<ul id="todo">
  <li><button class="done" data-id="1">Mark done</button> Water plants</li>
  <li><button class="done" data-id="2">Mark done</button> Pay rent</li>
</ul>
const list = document.querySelector("#todo");
list.addEventListener("click", (event) => {
  const done = event.target.closest("button.done");
  if (!done) return;
  done.closest("li").classList.toggle("is-done");
});

The memory angle

Delegation is also a memory play. Every direct addEventListener holds a small heap entry and a closure, and on single page apps a page that re-creates big rendered lists can leak these over a long session. One delegated listener on a stable container bounds the work to a single reference no matter how much the DOM changes. It is the difference between one cleanup point and a growing pile of orphan handlers on removed nodes.

When not to delegate

Delegation is the wrong choice a few times: mouseenter and mouseleave do not bubble, and focus and blur do not bubble either (use focusin and focusout instead). For capture on specific elements that are always present, a direct listener is simpler to read, so keep both patterns in the same file and document which element owns which. The events you end up debugging usually turn out to be bubbling from somewhere unexpected, which is where the troubleshooting order article helps, and heavy event work feeds the slow page route when a handler does more than the paint needs.

Need a website built, fixed, optimised, migrated or replaced?

This technical resource is written by CSMBAC, a small design and development studio. If you would rather hand the problem to a professional, the website service page explains how we build enquiry-ready websites.

Explore website services