What you'll be able to do

By the end of this page you should be able to attach one listener on a list (or table) parent, detect which row or button was clicked via event.target, and add new rows without wiring new handlers.

Previous post: event bubbling and capturing.

Who this is for

  • People who loop querySelectorAll("button") and addListener on every render
  • Beginners with dynamic todo lists
  • Anyone whose handler count grows with list length

You can skip this if delegation is already your default for lists. Come back when re-rendering duplicates listeners.

Pattern: listen on parent, filter target

const list = document.querySelector("#tasks");
list.addEventListener("click", function (event) {
  const btn = event.target.closest("button.delete");
  if (!btn) return;
  const row = btn.closest("li");
  row.remove();
});

Scenario A — delete buttons on dynamic rows. One listener on ul.

Scenario B — clicks on icons inside button. closest finds the button even if target is a span inside.

closest: walk up to a match

event.target.closest("[data-action]");

Scenario A — data-action="edit" on a button (see post #49).

Scenario B — ignore clicks on padding — if target is ul, closest("button") returns null; you return early.

Why delegation beats 100 listeners

ApproachDynamic rowsMemory
Listener per rowre-bind after each rendergrows with N
One delegated listenernew rows work automaticallyconstant

Bubble brings the event to the parent — you already learned why that happens.

A tiny practice file

Save as delegate.html.

<!DOCTYPE html>
<html>
  <body>
    <button id="add">Add row</button>
    <ul id="list"></ul>
    <script>
      let n = 0;
      document.getElementById("add").addEventListener("click", function () {
        n += 1;
        const li = document.createElement("li");
        li.innerHTML = 'Item ' + n + ' <button type="button" class="del">×</button>';
        document.getElementById("list").append(li);
      });
      document.getElementById("list").addEventListener("click", function (e) {
        const del = e.target.closest("button.del");
        if (del) del.closest("li").remove();
      });
    </script>
  </body>
</html>

Mistakes I see a lot

1. Not checking closest result before using it.

2. Delegating on document too early — fine for apps, but start with the smallest container.

3. stopPropagation on child that breaks delegation upstream.

4. Forgetting type="button" inside forms — default submit surprises.

What to try before the next post

  1. One listener removes any .del row.
  2. Add rows after load; delete still works.
  3. Log when click misses a button (early return).
  4. Build delegate.html.

Next in this series: DOMContentLoaded, defer, and async — script order without guessing.

Try this next outside the series

Browser JavaScript stops feeling isolated once you connect it to forms and component-oriented UI work.