A <ul> has a click handler that does `if (event.target.tagName === 'LI') { ... }`, but each <li> wraps its label in a <span>. Why does clicking the label do nothing, and how do you fix the dispatch?
answer
- works on the edge, not the label
- the hit element is not the row
- stop testing, start mapping upwards
- closest() walks ancestor-or-self
- guard null, guard containment
basics
~20 sevent.target is the innermost element the click landed on — the span, not the li — so the tagName check fails. Match with event.target.closest('li') instead, and verify the result is inside your own container before acting on it.
solid answer
~40 s`event.target` is whatever element the click actually landed on, which here is the inner `<span>`, so `tagName === 'LI'` is false and the branch never runs. Any markup change — wrapping the label, adding an icon, nesting a `<button>` — breaks a handler written that way, which makes the tagName check the classic delegation bug. The fix is to map the hit element up to the row instead of testing it directly: `const row = event.target.closest('li')`, which walks from the target upwards and returns the first ancestor-or-self that matches the selector. Then guard twice: `closest()` returns `null` when the click missed every row, and it happily walks past your container in nested lists, so also check `list.contains(row)`. In practice I match a marker selector like `li[data-id]` rather than a tag name.
code
javascript · 18 linesconst list = document.createElement('ul');
const li = document.createElement('li');
const span = document.createElement('span');
span.textContent = 'Buy milk';
li.dataset.id = '1';
li.append(span);
list.append(li);
document.body.append(list);
list.addEventListener('click', (event) => {
// Naive check: false whenever the click lands on the inner <span>
console.log('tagName check:', event.target.tagName === 'LI');
// Robust: map whatever was hit up to the row it belongs to
const row = event.target.closest('li[data-id]');
if (!row || !list.contains(row)) return;
console.log('row id:', row.dataset.id);
});go deeper
Recall that the click lands on the innermost element, so a check against the row's tag name fails whenever the row has any child markup. Reach for closest() to find the row.
Explain closest() as an ancestor-or-self walk, contrast it with matches(), and justify both guards — the null result and the containment check — as things you write by default, not after a bug report.
Show how you diagnose the intermittent version of this bug (works on the padding, not the label) by logging event.target, and argue for marker attributes over tag names so behaviour survives markup refactors.
Own the convention: define how elements opt into delegated behaviour across the codebase — a data-action vocabulary with a dispatch table — so handlers stay flat and presentational markup changes cannot silently break behaviour.
## What `event.target` refers to The `target` property of the event object is the element the interaction actually concerned — the innermost element under the pointer, not the element you registered the handler on. That is exactly the property delegation depends on: without it, a handler on the container would have no idea which child was involved. But it is also the source of the bug in the question, because "innermost element" is decided by the markup, and markup changes. Given `<li><span>Buy milk</span></li>`, clicking the words gives you the `<span>`. Clicking the few pixels of `<li>` padding outside the span gives you the `<li>`. So the handler works intermittently — it fires when you click the edge of a row and does nothing when you click the label, which is a maddening bug to reproduce from a report. ## Why tag-name checks are brittle by construction A `tagName` comparison asserts that the hit element and the element you care about are the same node. In a delegated handler they almost never are, and any of these routine edits breaks it: - wrapping the label in a `<span>` or `<strong>` for styling; - adding an icon element inside the row; - putting a nested `<button>` or `<a>` inside the row; - swapping `<li>` for a `<div role="listitem">`. The check also couples your JavaScript to the element name, so a purely presentational markup change becomes a behaviour regression. Nothing in the tooling warns you: it is valid code that silently stops matching. ## The fix: map upwards with `closest()` `Element.prototype.closest(selector)` starts at the element it is called on and walks up through ancestors, returning the first one that matches the selector, or `null` if it reaches the top without a match. It includes the starting element itself, so it handles both cases — hit the `<li>` directly, or hit something inside it — with one call. ```js list.addEventListener('click', (event) => { const row = event.target.closest('li[data-id]'); if (!row || !list.contains(row)) return; activate(row.dataset.id); }); ``` Use `Element.prototype.matches(selector)` instead when you deliberately want an exact hit — for example "only when the checkbox itself was clicked" — and `closest()` whenever you mean "which row is this in". ## The two guards you always want **Null result.** Clicks land on the container's own padding, on a header, on empty space below the last row. `closest()` returns `null` there, and code that immediately reads `row.dataset` throws a `TypeError`. An early `return` is the whole fix. **Containment.** `closest()` does not stop at the element you registered the handler on; it keeps walking to the document root. With nested lists — a list inside a list item of an outer list — an inner container's handler can return a row belonging to the outer list, and you act on the wrong item. `container.contains(row)` pins the match to your own subtree. (`Node.prototype.contains` returns `true` for the node itself as well, which is what you want here.) ## Marking rows explicitly Selecting on `li` alone matches every list item, including ones that are decorative, disabled, or belong to a different feature. Matching a deliberate marker — `li[data-id]`, or `[data-action]` for controls — makes the contract explicit: an element opts into the handler by carrying the attribute. It also gives you a natural dispatch key: ```js const handlers = { edit: editRow, delete: deleteRow }; const el = event.target.closest('[data-action]'); if (el && list.contains(el)) handlers[el.dataset.action]?.(el); ``` That scales better than a chain of tag-name tests and survives markup refactors, because the attribute travels with the element that owns the behaviour rather than with its tag name or position. ## Diagnosing it in the wild The symptom to recognise: a handler that works on some parts of a row and not others. When you see that, log `event.target` at the top of the handler and click the failing spot — you will see a child element you did not expect, and the fix is always the same shape: stop testing the hit element, start mapping it to the element you actually mean.
- When would you use matches() rather than closest() inside a delegated handler?When you deliberately want an exact hit rather than a mapping — "only if the checkbox itself was clicked, not the label around it". `matches()` tests just the element you call it on and returns a boolean, so it expresses "this element, precisely", while `closest()` expresses "whichever row this belongs to".
- Why add container.contains(row) when closest() already returned a match?Because `closest()` walks all the way to the document root, not to the element holding your handler. With a list nested inside another list's item, the inner handler can return a row belonging to the outer list and act on the wrong item. `contains()` pins the match to your own subtree.
- What breaks if a delegated handler reads row.dataset without checking the closest() result?A click on the container's own padding, its header, or the empty space under the last row returns `null` from `closest()`, and reading a property off it throws a TypeError that surfaces as a console error with no visible effect. An early return when the match is null is the entire fix.
saying these in an interview costs you the question
- Says event.target is always the element the handler is on
- Fixes it by adding pointer-events: none to every child
- Compares tagName instead of matching a selector
- Assumes closest() stops at the handler's own element
- Never guards against closest() returning null