What is event delegation in the DOM, and why does a single delegated handler still work for list items that are added after the handler was attached?
answer
- one listener, many children
- the container owns the handler
- identify the child from the event
- closest() maps the hit to a row
- new rows need no wiring at all
basics
~20 sEvent delegation puts one listener on a container instead of one per child and identifies the child from the event object. Items added later are covered automatically, because the listener lives on the container, not on each child.
solid answer
~50 sEvent delegation means registering a single listener on a container — say the `<ul>` — instead of one listener on every `<li>`, and then working out inside the handler which child the interaction actually concerned, usually with `event.target.closest('li')`. Because the listener belongs to the container and the container stays put, any `<li>` you append later is handled with no extra wiring: there is nothing to attach when a row appears and nothing to unregister when it goes away. That is why it shows up in list, table and infinite-scroll code, where rows arrive and disappear constantly. It also keeps registration at one call instead of N, and puts all the dispatch logic in one readable place. The guard matters though — check that `closest()` actually found a row inside your own container before acting on it.
code
javascript · 18 linesconst list = document.createElement('ul');
document.body.append(list);
list.addEventListener('click', (event) => {
const item = event.target.closest('li[data-id]');
if (!item || !list.contains(item)) return;
console.log('activated', item.dataset.id);
});
function addRow(id, label) {
const li = document.createElement('li');
li.dataset.id = id;
li.textContent = label;
list.append(li);
}
addRow('1', 'Write tests');
addRow('2', 'Ship it');go deeper
Be able to state the idiom in one sentence — one listener on the container, identify the child inside the handler — and write it for a list of items with closest().
Explain how the handler recovers the row from whatever element the interaction landed on, why closest() beats a tagName check, and why the two guards (null result, container.contains) belong in every delegated handler.
Show judgment about where the listener lives: the nearest ancestor that survives re-renders, not document, and be ready to explain what happens to the wiring when the container itself is replaced.
Frame delegation as a boundary between rendering and behaviour — rendering code emits markup with declarative data-action hooks, behaviour lives in one dispatch table — and argue when that indirection is worth the loss of local readability.
## What the pattern actually is Event delegation is a wiring decision, not an API. Instead of registering a handler on every element a user can interact with, you register one handler on an element that contains them all, and inside that handler you determine which descendant the interaction concerned. For a to-do list the listener goes on the `<ul>`; for a data grid, on the `<tbody>` or the wrapping `<div>`; for a toolbar, on the toolbar element itself. ```js const list = document.querySelector('#todos'); list.addEventListener('click', (event) => { const item = event.target.closest('li[data-id]'); if (!item || !list.contains(item)) return; console.log('activated item', item.dataset.id); }); ``` That is the whole idiom: one registration, one guard, one dispatch. Everything else — how many rows exist, when they were created, how often they are replaced — is irrelevant to the wiring. ## Why items added later are covered The listener is a property of the container, not of its children. When you append a new `<li>`, you create a node your setup code has never touched — and it does not need to be touched, because the element observing the interaction is still the container, and the container was wired once at startup. The event object handed to your handler carries a reference to the innermost element the interaction landed on, so the handler can identify the new row even though it did not exist when the handler was written. The practical consequence is a separation of duties. The code that renders rows becomes a pure "produce DOM" function with no knowledge that rows are clickable; the code that defines behaviour sits in one place next to the container. Removing a row is symmetric: it takes no per-row wiring away with it, because there was none. ## Identifying the right child The naive check `event.target.tagName === 'LI'` is fragile, because the interaction usually lands on something *inside* the row — a `<span>`, an icon, a nested `<button>`. `Element.prototype.closest(selector)` walks from the element upwards and returns the first ancestor-or-self that matches, which is exactly the mapping you want: "given whatever was hit, which row is it in?" `Element.prototype.matches(selector)` is the exact-match variant when you know the interaction lands directly on the element. Two guards are worth having by default. First, `closest()` returns `null` when nothing matches, so a delegated handler that acts on the result unguarded throws on interactions with the container's own padding. Second, `closest()` does not stop at your container — in nested lists it can return a row belonging to an outer list, so `container.contains(row)` keeps the dispatch honest. A common refinement is to mark rows and controls with a data attribute and dispatch on it: `event.target.closest('[data-action]')` plus a lookup table keyed by `dataset.action`. That turns a growing `if/else` chain into a flat map of behaviours and makes the markup declare what each control does. ## Where to attach the listener Pick the nearest ancestor that outlives the children. `document` works but is coarse: every such listener participates in every interaction on the page, and one careless handler can affect unrelated widgets. A listener registered on a container dies with that container, so if your update code replaces the container wholesale rather than emptying it, the wiring disappears with it — attach one level up, on the element that never gets replaced. ## What it costs and when to skip it Delegation trades per-element setup for per-event lookup: one registration and one short ancestor walk per interaction, instead of N registrations and none. For a list of five static buttons that never change, per-element handlers are simpler and the tradeoff is not worth reasoning about. Delegation earns its place when the set of children is large, changes often, or is produced by code that should not have to know about behaviour at all.
- If your update code replaces the whole container element on each render, what happens to the delegated handler?It goes away with the old container — the listener was registered on that node, and the replacement is a different node with no wiring. The fix is to attach to an ancestor that survives every render, or to empty and refill the same container instead of replacing it.
- How would you delegate when a container holds several different kinds of controls?Mark each control with a data attribute naming its action and dispatch through a lookup table: `const el = event.target.closest('[data-action]')`, then call `handlers[el.dataset.action]`. That keeps the handler flat as behaviours are added, and lets the markup declare what each control does instead of hiding it in a growing if/else chain.
- When would you still attach a handler per element instead of delegating?When the set is small and static, or when the element genuinely needs per-element setup that a shared handler cannot express. For five fixed buttons, direct handlers read better and cost nothing. Delegation pays off when children are numerous, are created and destroyed often, or are produced by rendering code that should stay behaviour-free.
It is a receptionist for the whole floor rather than a doorbell on every office door: visitors are received at one desk and routed onwards, and a new office opening down the hall needs no new doorbell.
saying these in an interview costs you the question
- Thinks delegation means attaching handlers in a loop over children
- Says newly added items still need the handler re-attached
- Reads event.target directly and assumes it is the row
- Believes a handler per item performs better than one delegated handler
- Attaches everything to document without considering a nearer container