JavaScriptEvent Delegation

Event Delegation

Imagine a todo list with 200 items, each with a delete button. Naively you would attach a click listener to every button — 200 listeners. With event delegation, you attach a single listener to the parent list and let bubbling do the work. It is the canonical way to handle events on large, dynamic, or unknown collections of elements, and it is one of the most useful patterns in vanilla JavaScript.

The idea in one sentence

Because events bubble, a single listener on a common ancestor can handle events that originate on any descendant. You ask the event "where did you come from?" and dispatch accordingly.

The minimal pattern

list.html

HTML
<ul id="tasks">
  <li><button data-action="done">Buy milk</button></li>
  <li><button data-action="done">Call mom</button></li>
  <li><button data-action="done">Ship feature</button></li>
</ul>

one listener for the whole list

JS
document.getElementById("tasks").addEventListener("click", (e) => {
  const btn = e.target.closest("button[data-action='done']");
  if (!btn) return;          // click was somewhere else inside the ul
  btn.closest("li").remove();
});

Three steps. Listen on the parent. Use event.target.closest(selector) to find the nearest matching ancestor (or the target itself). Bail out if there is no match.

Why closest, not target directly

event.target is the deepest node clicked. If the button contains an <svg> icon, a click on the icon makes target the SVG, not the button.element.closest(selector) walks up from the target until it finds a matching element (or returns null). That makes your matcher robust to "what exactly did the user click on?" details.

matters when children are nested

JS
// brittle — fails if there is an icon inside
if (e.target.matches("button.delete")) { /* ... */ }

// robust — works no matter what is inside the button
const btn = e.target.closest("button.delete");
if (btn) { /* ... */ }
Multiple actions in one listener

A common pattern is to put the action name in a data-* attribute and dispatch on it. You can replace dozens of individual handlers with a small switch.

HTML
<div class="toolbar">
  <button data-action="bold">B</button>
  <button data-action="italic">I</button>
  <button data-action="underline">U</button>
</div>

JS
document.querySelector(".toolbar").addEventListener("click", (e) => {
  const action = e.target.closest("button[data-action]")?.dataset.action;
  switch (action) {
    case "bold":      return document.execCommand("bold");
    case "italic":    return document.execCommand("italic");
    case "underline": return document.execCommand("underline");
  }
});
It works for elements added later

This is the killer feature. A listener attached to a specific button stops working the moment that button is removed and re-rendered. A delegated listener attached to the parent keeps working forever, because the events bubble up to the same ancestor every time.

works without rewiring after re-render

JS
function render(items) {
  list.innerHTML = items.map((t) =>
    `<li><button data-action="done">${t.title}</button></li>`
  ).join("");
}

list.addEventListener("click", (e) => {
  if (e.target.closest("[data-action='done']")) {
    e.target.closest("li").remove();
  }
});

render(initial);
render(updated); // no need to re-attach anything
Performance: dozens vs thousands

The performance argument is real but specific. Attaching one listener per row is fine for tens of rows. It starts to matter when you have thousands of rows, or when you re-render the list often (each render allocates fresh handlers and the old ones can leak if you forget to remove them).

  • Delegation: one persistent listener per kind of action. Memory is constant; setup is cheap.

  • Direct binding: N listeners, allocations on each render, plus cleanup discipline.

  • For typical UIs the win is correctness with dynamic content, not raw speed.

Events that do not bubble
Use the bubbling cousin
Some events do not bubble — `focus`, `blur`, `mouseenter`, `mouseleave`. Their bubbling siblings (`focusin`, `focusout`, `mouseover`, `mouseout`) exist precisely so you can delegate them.

JS
form.addEventListener("focusin", (e) => {
  if (e.target.matches("input")) {
    e.target.parentElement.classList.add("focused");
  }
});
Where to attach the listener

As close to the elements as makes sense — not always at document. The narrower the scope, the cheaper the filtering and the smaller the blast radius if you make a mistake.

  • A list of items → listen on the <ul>, not on document.

  • A modal with several actions → listen on the modal root.

  • Anything truly app-wide (keyboard shortcuts, "click outside") → listen on document or window.

Rule of thumb
Default to delegation for *collections of similar things*. Default to direct binding for *singular controls* (one submit button, one search input). Use `closest` to keep your matcher robust to inner markup.