◐ ai-teacher

🎨 Frontend & the Browser · Chapter 2

The DOM and event delegation

One listener on the document can handle every button in the app — including buttons that do not exist yet.

Study this lesson free in the app →

The basics

Event delegation means attaching one event listener to a shared ancestor — often document — instead of one to every button, and working out which button was clicked when the event arrives.

The problem it solves: apps that re-render by replacing HTML destroy and recreate their buttons constantly. If each button carried its own listener, you'd have to re-attach them after every render, and any you forget become silently dead controls. With delegation, nothing is attached to the buttons, so new ones work the instant they appear.

The mental model: the DOM is a tree of elements. A click doesn't just happen on the element you touched; the event travels down the tree to it, then bubbles back up through every ancestor. A listener at the top hears every click on the page. It then calls e.target.closest('[data-act]') — "walk up from what was clicked until you find a control" — and reads the action from the markup.

Words you must know 5

DOM
the browser's live tree of the page's elements
Bubbling
after the target, the event travels up through every ancestor
e.target
the deepest element actually clicked, often an icon inside the button
closest()
walks up from an element (starting with itself) to the first match
dataset
data-* attributes as properties: data-act → dataset.act

Explained

This app re-renders whole screens by assigning innerHTML. Every button on screen is destroyed and rebuilt.

With per-element listeners that means re-attaching handlers after every navigation, forever, and leaking the ones you forget.

It has one listener on document instead. New buttons work the moment they exist, because nothing was ever attached to them.

When you click something, the event does not just fire on that element. It travels:

  1. Capture — from document down to the element
  2. Target — fires on the element itself
  3. Bubble — back up to document

Delegation uses step 3. Listen on a common ancestor, and when an event arrives, ask what did this actually come from?

The question e.target.closest('[data-act]') answers is: "starting at whatever was clicked, walk up until you find an element with this attribute." That handles clicks on an icon inside a button, which is where naive e.target checks break.

The markup declares intent and carries its own data:

<button data-act="toggle-solved" data-id="dsa-arr-01" data-track="python">

No JavaScript knows that button exists. It works anyway.

That toggle-solved action is a good example of why this matters: the same tick appears in pattern notebooks, handbook question lists and lesson build tasks — three different screens, rendered by three different modules. Originally each bound its own handler. Consolidating them here deleted two copies of the same logic and guaranteed they cannot drift apart.

Why not just attach a listener to each button?

You can. It costs you three things.

// per-element: must re-run after every render
function bind(root) {
  root.querySelectorAll('.tick').forEach((b) => {
    b.onclick = () => { /* … */ };   // forget this call → dead button
  });
}

// delegated: written once, works forever
document.addEventListener('click', (e) => {
  const b = e.target.closest('.tick');
  if (b) { /* … */ }
});
  1. Re-binding. Every re-render needs the call. A missed one is a silently dead control.
  2. Memory. Listeners on removed nodes can keep them alive if anything else still references them.
  3. Drift. Three screens with the same button end up with three subtly different handlers.

When delegation is the wrong tool

Events that do not bubble. focus, blur, load, error. Use the capture phase ({capture: true}) or their bubbling twins focusin / focusout.

High-frequency events. Delegating mousemove or scroll to document runs your handler for the whole page. Attach those narrowly.

stopPropagation() upstream. Any handler between the target and your listener can stop the event reaching you — and this is genuinely hard to debug, because nothing errors.

Elements outside the container. Delegating to #app misses anything portalled elsewhere, like a modal appended to body.

Using e.target instead of e.target.closest(...). Click the <span> inside the button and e.target is the span. Your check fails and nothing happens — intermittently, which reads as flaky.

Forgetting return after handling. Without it, execution falls through into the next if and two actions fire.

Keeping state in JavaScript instead of the DOM. After innerHTML replaces everything, your variable points at a node that is no longer on the page. Reading data-on off the element avoids the whole class of bug.

*Delegating and also binding directly.* Both fire; the handler runs twice. Toggles appear to do nothing.

Listen high, identify low.

One listener on document, then e.target.closest('[data-act]') to find out what was really clicked.

Markup carries the intent (data-act) and its data (data-id). Buttons that do not exist yet already work.

"What is event delegation and why use it?"

"Instead of attaching a listener to every element, you attach one to a shared ancestor and use event bubbling. When an event arrives you call closest() on the target to find which control it came from.

Three benefits. It works for elements added after the listener was attached, which matters a lot if you re-render by replacing innerHTML. It is one listener instead of hundreds. And it puts the behaviour in one place, so the same control on different screens cannot drift apart.

In this app every click action is one listener on document, dispatched on a data-act attribute. The trade-off is that it does not work for events that do not bubble — focus and blur — and you should not delegate high-frequency events like mousemove to the document."

Words to know

BubblingAfter firing on the target, an event travels up through every ancestor to document.
CaptureThe downward phase before the target is reached. Opt in with {capture: true}.
e.targetThe deepest element the event actually started on — often a child of the control you care about.
closest()Walks up from an element until a selector matches. The core of delegation.
datasetThe data-* attributes of an element, read as properties. data-act becomes dataset.act.

In two minutes

Test yourself

1. Why use e.target.closest("[data-act]") instead of e.target?

  1. It is faster
  2. The click may land on a child element inside the button
  3. closest works without bubbling
  4. It avoids memory leaks
Show the answer

The click may land on a child element inside the button — e.target is the deepest element clicked. An icon inside a button would fail a direct check; closest walks up to the button.

2. What is the main advantage of delegation in an app that re-renders with innerHTML?

  1. Smaller HTML
  2. Handlers survive re-renders because nothing was attached to the elements
  3. It works offline
  4. It avoids CSS conflicts
Show the answer

Handlers survive re-renders because nothing was attached to the elements — The listener is on an ancestor that is never replaced, so newly created elements are handled without re-binding.

3. Which event will NOT reach a delegated listener on document?

  1. click
  2. focus
  3. input
  4. change
Show the answer

focus — focus does not bubble. Use focusin, or listen in the capture phase.

4. In what order does a click event travel?

  1. Bubble → target → capture
  2. Target → bubble only
  3. Capture → target → bubble
  4. Target → capture → bubble
Show the answer

Capture → target → bubble — Capture runs from document down to the target, then the target's own listeners fire, then the event bubbles back up. Listeners default to the bubble phase, which is why delegation 'just works'.

5. Clicks are delegated on #app. A new modal is appended directly to <body>, and its data-act buttons do nothing. Why?

  1. The modal is outside `#app`, so its clicks bubble up to body and document without ever passing the listener
  2. Modals block event bubbling by default
  3. `data-act` isn't allowed on dialog buttons
  4. Appending the modal removed the listener
Show the answer

The modal is outside `#app`, so its clicks bubble up to body and document without ever passing the listener — Bubbling only goes through ancestors. #app isn't an ancestor of the modal, so it never hears the click. Delegate on document, or mount modals inside the container.

6. A list of 5,000 transaction rows each needs click-to-expand, and the list is re-rendered on every filter change. Which approach?

  1. 5,000 listeners, one per row, re-attached after each render
  2. An inline `onclick=` attribute on each row
  3. A `setInterval` that checks which row is selected
  4. One listener on the `<ul>`, using `closest('li')` to find the row
Show the answer

One listener on the `<ul>`, using `closest('li')` to find the row — One listener survives every re-render and costs nothing per row. 5,000 listeners must be rebuilt on each filter change, and any reference to an old row keeps it in memory.

There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →