Skip to content
JavaScript js events 5 min read

Event Delegation

Event delegation is a pattern where you attach one listener to a shared ancestor instead of one listener per child. It works because most DOM events bubble: a click on a deeply nested element travels up through its parents, so a single handler on the container can react to clicks on any descendant. The result is less memory, simpler setup, and — crucially — handlers that automatically cover children added after the listener was registered.

Why delegate

Imagine a list of 500 rows, each with a delete button. Binding a separate click listener to every button means 500 functions, 500 closures, and 500 entries the browser must track. Worse, if you later append a new row, its button has no listener until you remember to wire one up.

Delegation solves both problems at once. You bind a single listener to the parent <ul>, and inside it you inspect event.target to figure out which child was actually clicked. Add, remove, or replace children freely — the parent listener keeps working with zero extra bookkeeping.

ApproachListeners for N itemsWorks for dynamic children?Cleanup
One listener per childNNo — must rebind on addRemove N listeners
Delegation (one on parent)1Yes — automaticRemove 1 listener

The core pattern: event.target and closest

When an event bubbles to the parent, two properties matter. event.currentTarget is the element the listener is attached to (the parent). event.target is the element where the event actually originated (the clicked child).

The naive version checks event.target directly, but that breaks the moment a button contains other markup — clicking the icon inside a button makes target the icon, not the button. The robust fix is Element.closest(selector), which walks up from target and returns the nearest matching ancestor (or itself), or null if none matches.

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

list.addEventListener("click", (event) => {
  const button = event.target.closest("button.delete");
  if (!button) return; // click was outside any delete button — ignore it

  const row = button.closest("li");
  console.log("delete row:", row.dataset.id);
  row.remove();
});

Output:

delete row: 42

Always guard with if (!closestMatch) return;. Without it, clicks on empty space inside the container throw because you try to read properties of null.

A delegated to-do list

The demo below wires the entire list — toggling, deleting, and even items added at runtime — through a single listener on the <ul>. Notice that the “Add” button creates new <li> elements that immediately respond, with no new listeners attached.

<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: system-ui, sans-serif; padding: 24px; }
    ul { list-style: none; padding: 0; max-width: 360px; }
    li { display: flex; align-items: center; gap: 8px; padding: 8px;
         border-bottom: 1px solid #ddd; }
    li.done span { text-decoration: line-through; color: #999; }
    li span { flex: 1; cursor: pointer; }
    .delete { margin-left: auto; cursor: pointer; }
    form { display: flex; gap: 8px; margin-bottom: 12px; }
    input { flex: 1; padding: 6px; }
    button { padding: 6px 12px; cursor: pointer; }
  </style>
</head>
<body>
  <form id="add-form">
    <input id="new-task" placeholder="Add a task..." />
    <button type="submit">Add</button>
  </form>

  <ul id="todos">
    <li data-id="1"><span>Learn delegation</span><button class="delete">x</button></li>
    <li data-id="2"><span>Ship the feature</span><button class="delete">x</button></li>
  </ul>

  <script>
    const todos = document.querySelector("#todos");
    let nextId = 3;

    // ONE listener handles toggle + delete for every current and future item.
    todos.addEventListener("click", (event) => {
      if (event.target.closest(".delete")) {
        event.target.closest("li").remove();
        return;
      }
      const span = event.target.closest("span");
      if (span) span.parentElement.classList.toggle("done");
    });

    document.querySelector("#add-form").addEventListener("submit", (event) => {
      event.preventDefault();
      const input = document.querySelector("#new-task");
      const text = input.value.trim();
      if (!text) return;

      const li = document.createElement("li");
      li.dataset.id = String(nextId++);
      li.innerHTML = `<span></span><button class="delete">x</button>`;
      li.querySelector("span").textContent = text; // safe: avoids HTML injection
      todos.appendChild(li);
      input.value = "";
    });
  </script>
</body>
</html>

Reading data from the matched element

Because the handler is generic, you usually need to know which item was acted on. The cleanest channel is a data-* attribute read through the dataset API, which keeps your logic out of class names and text content.

<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: system-ui, sans-serif; padding: 24px; }
    button { font-size: 1rem; padding: 10px 16px; margin: 4px; cursor: pointer; }
    #out { margin-top: 16px; font-family: monospace; }
  </style>
</head>
<body>
  <div id="toolbar">
    <button data-action="save">Save</button>
    <button data-action="copy">Copy</button>
    <button data-action="delete">Delete</button>
  </div>
  <div id="out">Click a button.</div>

  <script>
    const handlers = {
      save: () => "Saved!",
      copy: () => "Copied to clipboard.",
      delete: () => "Deleted.",
    };

    document.querySelector("#toolbar").addEventListener("click", (event) => {
      const btn = event.target.closest("button[data-action]");
      if (!btn) return;
      const run = handlers[btn.dataset.action];
      document.querySelector("#out").textContent = run ? run() : "Unknown action";
    });
  </script>
</body>
</html>

Pitfalls

Delegation is powerful but has sharp edges worth knowing.

  • Non-bubbling events. A few events do not bubble — notably focus, blur, mouseenter, and mouseleave. Delegate with their bubbling counterparts instead: focusin/focusout, and mouseover/mouseout.
  • event.target vs currentTarget. Use target (or closest from it) to find what was hit; use currentTarget for the parent. Mixing them up is the most common delegation bug.
  • Over-broad containers. Delegating on document for everything makes a single function responsible for the whole page and runs on every click. Scope to the smallest sensible ancestor.
  • stopPropagation inside children. If a child handler calls event.stopPropagation(), the event never reaches your delegated parent listener. Avoid stopping propagation unless you truly need to.

Best Practices

  • Attach the listener to the nearest stable ancestor, not document, to limit how often the handler runs.
  • Use event.target.closest(selector) rather than checking event.target directly so clicks on nested markup still match.
  • Always bail early with if (!match) return; before touching the matched element.
  • Carry item identity in data-* attributes and read it via element.dataset, keeping logic decoupled from text and classes.
  • For non-bubbling events, delegate with the bubbling alternative (focusin, mouseover) or fall back to direct listeners.
  • Prefer delegation for lists, tables, and any UI whose children change at runtime; it removes an entire class of rebinding bugs.
Last updated June 1, 2026
Was this helpful?