Skip to content
academia.sh

Lesson 03 / 24

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.

Contents

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.

// 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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close