Event Delegation

Use event bubbling deliberately to attach a single listener to a shared ancestor, correctly identify which descendant triggered it, and handle dynamically added elements without re-binding listeners.

Event delegation is a pattern, not a separate API — it is simply taking advantage of bubbling on purpose. Instead of attaching a listener to every individual button in a list of a hundred items, you attach one listener to the list's container and let clicks bubble up to it, then figure out which specific item was clicked using event.target.

Why delegate: dynamic elements

If you attach a click listener directly to each item in a list, that works fine for items that exist at the time the listener code runs — but any item added to the page later (say, a new row appended after an API call, or a new todo item created by the user) will have no listener at all, because addEventListener only attaches to elements that exist at that moment.

Delegation solves this automatically: because the listener lives on a stable parent that never gets removed, and bubbling carries every click on any current or future child up to that parent, new elements work correctly the instant they are added to the DOM — with zero extra code required to "re-bind" anything.

How delegation works

The technique relies entirely on the bubbling phase you already saw in the previous lesson: a click on a descendant bubbles up through every ancestor, including the one you chose to attach a single listener to. The container's listener runs once per click, no matter which of its (possibly many) descendants was actually clicked.

Using event.target and closest()

Inside the container's listener, event.target tells you the most specific element that was actually clicked — which might be the button you care about, or it might be a <span> or icon nested inside that button, depending on exactly where the user clicked.

The reliable way to find "the meaningful ancestor I actually care about" from event.target is element.closest(selector), which walks up from event.target (including event.target itself) and returns the nearest ancestor matching the given CSS selector, or null if none matches. This makes delegated handlers robust to markup changes inside the clickable element.

Performance benefits

One listener on a container is cheaper — in both memory and setup time — than hundreds of individual listeners on hundreds of children, which matters for large, frequently-updated lists (comment threads, data tables, infinite-scroll feeds). It also completely removes the bookkeeping problem of remembering to add (and remove) a listener every time an item is created or destroyed.

Practical example

A todo list is the canonical use case: a single click listener on the <ul> handles clicks on "delete" buttons inside any <li>, whether that <li> existed on page load or was created by the user five minutes later by typing into an "add item" field.

Code Examples

A single listener on the <ul> handles delete clicks for every <li>, present now or added later. closest(".delete") correctly identifies the button even if the click landed on an icon nested inside it, and returns null (so the handler exits early) if the click was on something unrelated, like the item's text. Expected result: (clicking a delete button removes that specific <li> from the page) if (!deleteBtn) return; guards against clicks that bubbled up from parts of the list that are not delete buttons.

Notice that the delete-click listener is set up only once, before any items even exist, yet it correctly handles every item created afterward by the submit handler — no per-item addEventListener call is ever needed. Expected result: (typing an item and submitting adds it; clicking its × removes it, even though it was created after the page loaded)

The delegated listener was attached before any <li> existed, proving it does not depend on the children being present at setup time.

const list = document.querySelector("#todo-list");

list.addEventListener("click", function (event) {
  const deleteBtn = event.target.closest(".delete");
  if (!deleteBtn) return; // click happened somewhere else in the list

  const item = deleteBtn.closest("li");
  item.remove();
});
const list = document.querySelector("#todo-list");
const addForm = document.querySelector("#add-form");
const input = document.querySelector("#new-item-text");

addForm.addEventListener("submit", function (event) {
  event.preventDefault();
  const li = document.createElement("li");
  li.innerHTML = input.value + ' <button class="delete">\u00d7</button>';
  list.appendChild(li);
  input.value = "";
});

list.addEventListener("click", function (event) {
  const deleteBtn = event.target.closest(".delete");
  if (deleteBtn) deleteBtn.closest("li").remove();
});

One listener, many children

No matter how many <li> items exist, or when they were added, a click anywhere inside the list bubbles up to the one listener on <ul id="todo-list">.

<ul id="todo-list">  ◄── single addEventListener("click", ...) lives here
     │
     ├── <li><button class="delete">×</button> Buy milk</li>
     ├── <li><button class="delete">×</button> Walk dog</li>
     └── <li><button class="delete">×</button> Learn JS</li>   ◄── added LATER, still works

  Click on any "×" button bubbles up to #todo-list,
  where event.target.closest(".delete") identifies it.

Important Rules

Delegation means attaching one listener to a stable ancestor instead of many listeners to its children.

It relies entirely on event bubbling — it does not work for events that do not bubble (like focus or mouseenter) unless you use their bubbling equivalents (focusin, mouseover).

Use event.target.closest(selector) inside the handler to reliably find the meaningful clicked element.

New children added after the listener was set up are handled automatically, with no extra code.

Where this shows up

Comment sections, data tables with per-row action buttons, shopping cart line items, and any list rendered from an API response all use delegation, because the exact number and identity of rows is not known until runtime and can change at any time. Frameworks like React use a related idea internally (a single root-level listener) for the same performance reasons.

if (event.target.classList.contains("delete")) {... } const deleteBtn = event.target.closest(".delete"); if (deleteBtn) {... } event.target is the exact innermost element clicked, which might be a child of the button (an icon or span) rather than the button itself. Checking event.target directly misses those clicks; closest() searches upward from event.target and finds the button reliably.

[Multiple Choice] What DOM behavior makes event delegation possible? A. Event capturing B. Event bubbling C. The once option D. The passive option

B. Event bubbling — Delegation works because clicks (and most other events) bubble upward from the target through every ancestor, allowing a single listener on a shared ancestor to observe events from any of its descendants.

[True / False] A delegated click listener set up on a container automatically works for child elements added to that container after the listener was registered. A. True B. False

A. True — True — because the listener lives on the stable container and bubbling happens at click time (not at listener-registration time), any element added later that is a descendant of the container is covered without any extra setup.

You have a <div id="gallery"> containing many <img class="thumb"> elements, with more added dynamically over time. Write a single delegated listener that logs the src of whichever thumbnail image is clicked.

const gallery = document.querySelector("#gallery"); gallery.addEventListener("click", function (event) { const thumb = event.target.closest(".thumb"); if (!thumb) return; console.log("Clicked thumbnail:", thumb.src); });

Attach the listener to #gallery, and inside it use event.target.closest(".thumb").