Picture a user table that loads 50 rows, then more as you scroll, and each row has a Remove button. The first version usually loops over querySelectorAll('button') and attaches a listener to each one. It works until the next page of rows arrives: the new buttons have no listener, because the loop ran before they existed. Someone adds a second loop after every fetch, and now the old rows get two listeners and delete twice.
Event delegation removes the whole problem. You put one listener on an element that already exists and will outlive the rows (the <ul>, the table body), and let clicks bubble up to it. When the event arrives, the handler works out which row and which action the user meant. Rows added later need nothing, because they sit inside the element that’s listening.
Interviewers like this topic because the one-line explanation is easy and the details aren’t: event.target versus the element you care about, events that don’t bubble, and component and shadow DOM boundaries.
How a click reaches the parent
A DOM event travels in three phases. In the capture phase it goes down from window through each ancestor to the target. At the target, listeners on the element itself run. Then, for events that bubble, it goes back up through the same ancestors to window. A plain addEventListener('click', fn) listens during the bubble phase, so a listener on the <ul> runs after the click has passed through the <li> and the button.
Two properties tell you where you are. event.target is the deepest element the event was dispatched to, the thing physically under the pointer. event.currentTarget is the element whose listener is running right now, which in a delegated handler is your <ul>. Mixing them up is the first mistake interviewers listen for. People write event.target.dataset.userId and it works in testing, because they clicked on the button’s padding. Then someone adds an icon.
Why event.target.matches() breaks when the button has an icon
Here’s the markup the examples below use. The Remove button contains an SVG and a text label, and the list sits inside a collapsible card that has its own data-action.
<section class="card" data-action="collapse">
<ul id="users">
<li>
Ada Lovelace
<button type="button" data-action="remove" data-user-id="17">
<svg width="10" height="10"><path d="M0 0L10 10" /></svg>
Remove
</button>
</li>
</ul>
</section>If the user clicks the cross icon, the target is the <path>, not the button. A handler that tests event.target.matches('[data-action]') sees false and quietly does nothing. That’s the bug report you get: “Remove only works if you click next to the icon.”
The fix is Element.closest(), which starts at the element itself and walks up through its ancestors until one matches the selector, returning null if none does. From the <path>, closest('[data-action]') passes the <svg> and lands on the button.
const list = document.querySelector('#users');
function onClick(event) {
if (!(event.target instanceof Element)) return;
const control = event.target.closest('[data-action]');
if (!control || !list.contains(control)) return;
if (control.dataset.action === 'remove') {
removeUser(control.dataset.userId);
}
}
list.addEventListener('click', onClick);Run this in a browser against the markup above, dispatching a bubbling click from the <path>, then from the bare <li> text, then from a row inserted afterwards with insertAdjacentHTML, and the log reads:
target tag: path, matches? false
remove user 17
ignored SECTION
remove user 42The first line is the trap. The second shows closest() recovering the button. The fourth shows a row that didn’t exist when the listener was attached working with no extra code. The third line is the interesting one.
The containment check is not decoration
Clicking the name text in the <li> makes closest('[data-action]') walk up from the <li>, find nothing in the list, keep going past the <ul>, and match the <section data-action="collapse">. closest() has no idea where your delegation root is. Without list.contains(control), a click on any row’s text would be treated as a collapse action by the list’s handler, and the card would fold shut while the user was trying to select a name.
The instanceof Element guard is cheap insurance for when the same handler gets reused on events whose target might be document, which has no closest.
A nested version of the same problem appears when one delegated list contains another, such as comments with replies. The outer list’s handler also receives clicks from the inner list, because the inner list is inside it. If both lists use [data-action], each click runs twice. Either make the selector specific to the list ([data-comment-action]), or check that the matched control belongs to this list and not a nested one, for example control.closest('ul') === list.
Events that don’t bubble, and the listener that never hears them
Delegation relies on bubbling, and several common events don’t bubble:
focusandblurdon’t bubble. Their bubbling twins arefocusinandfocusout, which is what you want for “highlight the row that contains the focused input.”mouseenterandmouseleavedon’t bubble, by design: they fire once per element boundary. Usemouseoverandmouseoutfor delegated hover, and filter withclosest()andrelatedTarget.loadanderroron an<img>don’t bubble. A delegated “show a placeholder when any thumbnail fails” handler won’t fire unless you register it with{ capture: true }, which catches the event on the way down instead.
In the same test page, attaching mouseenter and mouseover listeners to the <ul> and dispatching each event from an <li> with its standard bubbling flag records only mouseover@ul.
The other way a delegated listener goes deaf is stopPropagation(). A third-party dropdown inside a row calls it to stop its menu closing, and now your row handler never sees clicks from that area. In the test, a button that called event.stopPropagation() produced no log line at the list. You can’t fix this from the delegated side except with a capture listener higher up, which runs before the target. The better fix is to make the inner component stop propagating only what it has to, or to check event.defaultPrevented in the outer handler instead of relying on propagation being stopped.
Shadow DOM changes what target means
If a row is a web component with an open shadow root, a click on a button inside it reaches your light-DOM listener retargeted: event.target is the host element, not the inner button. The browser does this so outside code can’t see the component’s internals.
Put a <user-row> with a shadow-root button (data-user-id="99") into the same list and click it. At the <ul> listener, event.target.tagName is USER-ROW, and closest('[data-action]') walks up to the SECTION again, which the containment check rejects. The click is lost. event.composedPath() still lists the real path, starting at the BUTTON, so this works for open shadow roots:
const control = event
.composedPath()
.find((node) => node instanceof Element && node.matches('[data-action]'));For a closed shadow root the path stops at the host, and the right answer is for the component to dispatch its own custom event (new CustomEvent('remove-user', { bubbles: true, composed: true, detail: { id } })).
Keep the buttons real
Delegation changes where the listener lives. It shouldn’t change what the element is. Use a <button type="button"> for actions and <a href> for navigation. A native button fires click when the user presses Enter or Space, so the delegated click handler covers keyboard users for free. A <div data-action="remove"> doesn’t, and it isn’t announced as a button by screen readers. Disabled buttons don’t dispatch click events, which is usually what you want.
Treat data-user-id as untrusted input from the page. It came from markup that anything with DOM access can change, so the server still has to check that this user may delete record 17.
If the delegation root is a long-lived element such as document and the feature that installed the listener is torn down, call removeEventListener with the same function reference (or pass an AbortSignal in the options and abort it). Otherwise the handler and everything its closure holds stay reachable.
When delegation is the wrong tool
For a toolbar with four buttons that never change, individual listeners are clearer. The cost of one listener per row matters at thousands of rows, not dozens. Delegation pays off when children are created and destroyed often, or when you’d otherwise re-bind after every update. React already delegates internally (to the root container since React 17), which is why you rarely write this pattern by hand there, but the target and stopPropagation rules still apply.
Interview practice
A task list renders rows with innerHTML. Each row has a delete button containing an icon font <i>. The code is:
taskList.addEventListener('click', (e) => {
if (e.target.classList.contains('delete')) deleteTask(e.target.dataset.id);
});Users say delete “works sometimes.” Later, a teammate adds a modal component inside the page that also uses class="delete" buttons, and some modal clicks start deleting tasks. Explain both bugs and fix the handler.
Answer
The first bug is that e.target is whatever element was under the pointer. Clicking the icon makes the target the <i>, which doesn’t have the delete class, so nothing happens. Clicking the button’s padding targets the button, so it works. Hence “sometimes.”
The second bug depends on where the modal is mounted. If it ends up inside taskList’s subtree (say, rendered next to the row that opened it), its clicks bubble to the list and its .delete buttons pass the class check. The handler has no idea which component owns the button.
The fix resolves the control with closest(), scopes the selector to this feature, and confirms ownership:
taskList.addEventListener('click', (e) => {
if (!(e.target instanceof Element)) return;
const button = e.target.closest('button[data-task-delete]');
if (!button || !taskList.contains(button)) return;
deleteTask(button.dataset.id);
});A strong answer also mentions that the id still needs server-side authorization and that the buttons should be real <button> elements so Enter and Space work.