---
title: 'Event Delegation'
source: 'https://academia.sh/en/courses/browser-platform/event-delegation'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:11+00:00'
license: 'CC BY-SA 4.0'
---

# Event Delegation

Serving many elements' events with a single listener bound to an ancestor; telling the target apart from the current target, finding the closest matching ancestor, carrying identity in the markup, and delegation's limits.

The previous lesson showed that an event passes through its ancestors both before and
after reaching the target. This journey is not a burden, it is an opportunity: an
ancestor's listener sees the events of every element beneath that ancestor. Instead of
binding a separate listener to every row of the measurement table, a single listener can
be bound to the table.

This technique is called **event delegation**. Delegation working rests on a single
distinction: the listener is bound to an ancestor node, but the element producing the
event is the target. The listener has to resolve which descendant element the event it
received came from.

## Two Problems

Direct binding produces two separate problems, and the second is more insidious.

The first is **registration count**. A listener per row in a hundred-row table means a
hundred registrations. Every registration holds an entry in memory, and every
registration creates a bond that has to be cleaned up when the element is removed from
the tree.

The second is **lifetime**. A listener is bound to the element that exists at the moment
it is registered. A row added after the page has loaded stays without a listener, because
it did not exist at registration time. This is the behavioral counterpart of the live
collection versus static list distinction from the DOM API lesson: a bond established at
a static moment does not cover nodes that arrive later.

Delegation solves both problems at once. The listener is bound to an ancestor that
outlives the rows — the table. Rows come and go, but the table stays put.

## Resolving the Target

A delegated listener's first job is deciding whether the incoming event came from an
element it cares about. Two operations are commonly confused here.

**Match testing** says whether a single element satisfies a given selector; it looks only
at that element. **Closest-ancestor search** starts from the target, walks the ancestor
chain upward, and returns the first node matching the selector.

The difference shows up in where the click lands. If a delete button contains an icon
element, when the user presses the button, the target is not the button but the icon.
Code that tests only the target misses this click.

```js
// closest.mjs — matching search that walks upward from the target to a boundary
const element = (name, attrs = {}, ...children) => {
  const d = { name, attrs, children, parent: null };
  for (const c of children) c.parent = d;
  return d;
};

const label = (d) =>
  d.name + (d.attrs.id ? "#" + d.attrs.id : "") + (d.attrs.class ? "." + d.attrs.class : "");

const matches = (d, selector) => {
  if (selector.startsWith(".")) return (d.attrs.class ?? "").split(/\s+/).includes(selector.slice(1));
  if (selector.startsWith("#")) return d.attrs.id === selector.slice(1);
  return d.name === selector;
};

// Starts at the target, walks upward to the boundary; the boundary is also tried, nothing past it.
const closest = (target, selector, boundary) => {
  for (let d = target; d; d = d.parent) {
    if (matches(d, selector)) return d;
    if (d === boundary) break;
  }
  return null;
};

const icon = element("span", { class: "icon" });
const button = element("button", { class: "delete" }, icon);
const row = element("tr", { class: "measurement", "data-code": "T-04" }, element("td", {}), element("td", {}, button));
const table = element("table", { id: "log" }, row);

console.log("target        :", label(icon));
console.log("current target:", label(table));
console.log("matches(target, '.delete')  :", matches(icon, ".delete"));
console.log("closest('.delete')          :", label(closest(icon, ".delete", table)));
console.log("closest('tr')                :", label(closest(icon, "tr", table)), closest(icon, "tr", table).attrs["data-code"]);
console.log("closest('body')              :", closest(icon, "body", table));
```

```
target        : span.icon
current target: table#log
matches(target, '.delete')  : false
closest('.delete')          : button.delete
closest('tr')                : tr.measurement T-04
closest('body')              : null
```

The third line shows the problem: the target itself is not the delete button. The fourth
line shows the fix: the upward search finds the button. The fifth line goes one step
further and finds the row the button belongs to; the measurement's code is read from
there.

The last line shows the **boundary** concept. The search stops at the current target.
Without this boundary, the search would climb all the way to the document's root and
could return a node outside the listener's scope, leading to an operation being performed
on another component's element. In the browser API, these operations are named `matches`
and `closest`; because `closest` takes no boundary, whether the result stays under the
current target is checked separately.

## Setting Up Delegation

The behavioral difference between the two approaches shows up on a row added later.

```js
// delegation.mjs — the difference between binding to every element and delegating with one listener
const element = (name, attrs = {}, ...children) => {
  const d = { name, attrs, children, parent: null, listeners: [] };
  for (const c of children) c.parent = d;
  return d;
};
const matches = (d, s) =>
  s.startsWith(".") ? (d.attrs.class ?? "").split(/\s+/).includes(s.slice(1)) : d.name === s;
const closest = (target, s, boundary) => {
  for (let d = target; d; d = d.parent) { if (matches(d, s)) return d; if (d === boundary) break; }
  return null;
};
// Bubble phase: every node's listeners are called from target to root.
const click = (target) => {
  for (let d = target; d; d = d.parent)
    for (const listener of d.listeners) listener({ target, currentTarget: d });
};
const listenerCount = (root) =>
  root.listeners.length + root.children.reduce((t, c) => t + listenerCount(c), 0);

const buildRow = (code) =>
  element("tr", { class: "measurement", "data-code": code }, element("td", {}), element("td", {}, element("button", { class: "delete" })));
const buildTable = () =>
  element("table", { id: "log" },
    element("tr", { class: "heading" }, element("th", {})),
    buildRow("T-01"), buildRow("T-02"), buildRow("T-03"));

const buttons = (root) =>
  [root, ...root.children.flatMap((c) => buttons(c))].filter((d) => matches(d, ".delete"));

let deleted = [];
const deleteRow = (row) => deleted.push(row.attrs["data-code"]);

// First way: a separate listener on every delete button.
const a = buildTable();
for (const b of buttons(a)) b.listeners.push(({ currentTarget }) => deleteRow(currentTarget.parent.parent));
console.log("direct binding, listeners:", listenerCount(a));
a.children.push(Object.assign(buildRow("T-04"), { parent: a })); // row added later
deleted = []; click(buttons(a)[0]); console.log("  T-01 button:", deleted.length ? deleted : "no action");
deleted = []; click(buttons(a)[3]); console.log("  T-04 button:", deleted.length ? deleted : "no action");

// Second way: a single listener on the table; the target is re-resolved on every call.
const b = buildTable();
b.listeners.push(({ target, currentTarget }) => {
  const button = closest(target, ".delete", currentTarget);
  if (button === null) return;
  deleteRow(closest(button, "tr", currentTarget));
});
console.log("delegation, listeners     :", listenerCount(b));
b.children.push(Object.assign(buildRow("T-04"), { parent: b }));
deleted = []; click(buttons(b)[0]); console.log("  T-01 button:", deleted.length ? deleted : "no action");
deleted = []; click(buttons(b)[3]); console.log("  T-04 button:", deleted.length ? deleted : "no action");
deleted = []; click(b.children[0].children[0]); console.log("  heading cell:", deleted.length ? deleted : "no action");
```

```
direct binding, listeners: 3
  T-01 button: [ 'T-01' ]
  T-04 button: no action
delegation, listeners     : 1
  T-01 button: [ 'T-01' ]
  T-04 button: [ 'T-04' ]
  heading cell: no action
```

In direct binding, the listener count grows along with the row count, and a row added
later stays silent. In delegation, there is a single registration, and a row added later
works exactly like the first row. The source of the difference is timing: in direct
binding, the match is established **at registration time**; in delegation, **at event
time**.

The last line shows delegation's second requirement. The listener bound to the table sees
every click beneath the table — the heading cell, an empty cell, the gap between rows.
When no match is found, the listener returns without doing anything. This early exit is
the first line of every delegated listener.

## Carrying Identity in the Markup

A delegated listener has to know which record the event belongs to. Keeping this
information as a sequence number in an array is fragile: when rows are sorted or
filtered, the sequence number shifts.

The solid way is carrying the identity on the element itself. The `data-` attributes
defined in the Data Attributes lesson of the Web Fundamentals and HTML course exist for
this job: the value stays in the markup, and even if the element moves in the tree, it
moves along with its value. In the example above, a row's measurement code is read this
way.

The same attributes can also carry the action type. If a row has delete, edit, and mark
buttons, instead of testing a separate selector for each, the action name is kept in a
shared attribute on the buttons; the listener does a single lookup and calls the action
from a mapping. This makes the listener's length independent of both the row count and
the action count.

## Delegation's Limits

Delegation does not work for every event type and has two limits.

**Non-bubbling events cannot be delegated.** As defined in the previous lesson,
focus-gain and focus-loss events do not bubble; a bubble-mode listener bound to an
ancestor does not see them. These events' bubbling counterparts are defined as separate
types, and delegation is built with those. The second route is registering the listener
on the ancestor in **capture mode**: because the capture phase also runs for non-bubbling
events, the ancestor node does see the event. The choice between the two routes depends
on whether the event needs to be handled before or after it reaches the target.

**An intermediate listener that stops propagation silently breaks delegation.** A listener
delegated to the table is never called if a listener inside the row calls
`stopPropagation`. The previous lesson said stopping propagation is a last resort; in a
tree with delegation set up, this turns into a remote-acting interruption.

A third point is not a limit but a design decision: the higher up the tree the ancestor a
listener is bound to, the wider its scope. A listener bound to the entire document runs on
every click on the page. Delegation is set up on the elements of interest' **common
closest ancestor**; the table's events are delegated to the table, the form's events to
the form.

## Summary

- Event delegation is binding a single listener to an ancestor of the elements of
  interest and resolving the target at event time; it makes the listener count
  independent of the element count.
- Delegation also covers elements that do not exist at registration time; because the
  match is established at event time rather than registration time, later-added nodes do
  not stay without a listener.
- The target resolves to the closest matching ancestor; code that tests only the target
  misses clicks coming from nested elements.
- The search stops at the current target; an unbounded search can return a node outside
  the listener's scope.
- Registration identity is carried on the element itself with `data-` attributes; mapping
  based on a sequence number breaks after sorting and filtering.
- Non-bubbling events cannot be delegated in bubble mode; capture mode or the bubbling
  counterpart type is used. An intermediate listener stopping propagation silently
  cancels delegation.

## Next Step

Up to this lesson, listeners only **added**: an event arrived, code ran, a behavior was
attached to the page. But the browser has its own behaviors too, and these work without a
listener. Clicking a link starts navigation, a submit button submits the form, a checkbox
toggles its checked state, the space bar scrolls the page. When a listener wants to put
its own behavior in place of these, it has to cancel the work the browser was going to do.
The next lesson covers how this cancellation is requested, which events it is possible
for, and why it should not be confused with stopping propagation.
