skip to content

During a DOM click dispatch, a handler near the target removes the clicked element from the document. Why do listeners on its former ancestors still run, and what do event.target.closest() and event.composedPath() each return after that removal?

level: seniorimportance: nice to knowfreq 28%

answer

  1. computed once, then walked
  2. the tree changed, the itinerary did not
  3. isConnected goes false, target does not
  4. one lookup re-derives, one replays
  5. last two entries are not elements

basics

~20 s

The browser computes the propagation path once, when dispatch begins, so removing the target mid-dispatch does not reroute the event and ancestor listeners still run. closest() then walks the detached node's remaining ancestors and can return null, while composedPath() returns the path recorded at dispatch.

solid answer

~50 s

Dispatch is not a live tree walk. When the browser dispatches an event it first builds the propagation path from the tree as it stands at that instant, then walks that fixed list. So a handler that detaches the target does not cancel the rest of the journey — the listeners on the former ancestors, including a delegated one on `document`, still run, and `event.target` still points at the now-orphaned node. What breaks is anything that re-derives the ancestry from the live DOM: `event.target.closest('.row')` climbs the *detached* node's remaining parents and returns `null` once the matching ancestor is no longer above it. `event.composedPath()` does not re-derive anything — it returns the array recorded when dispatch began, ordered target-first out to `window` — so scanning that array still identifies the row. This is the standard reason a delegated handler behaves differently depending on whether an inner handler re-rendered first.

code

javascript · 16 lines
javascript
const container = document.createElement('div');
container.innerHTML = '<div class="row"><button>del</button></div>';
document.body.append(container);

container.querySelector('button').addEventListener('click', () => {
  container.replaceChildren(); // detaches the clicked button entirely
});

document.addEventListener('click', (e) => {
  console.log('isConnected:', e.target.isConnected);            // false
  console.log('closest:', e.target.closest('.row'));            // null
  const row = e.composedPath().find((n) => n instanceof Element && n.matches('.row'));
  console.log('from composedPath:', row);                       // the detached row
});

container.querySelector('button').click();

go deeper

for a junior

Know that event.target keeps pointing at the clicked element even after it is removed from the page, and that isConnected tells you whether it is still attached.

for a middle

Explain that the propagation path is computed once at dispatch, so later DOM mutation does not reroute or cancel the event, and that closest() reads the live tree while composedPath replays the recorded one.

for a senior

Show the debugging instinct: resolve identity at the top of a delegated handler before anything re-renders, and prefer a data- attribute or a composedPath scan over ancestor traversal in mutating code.

for a principal

Own the pattern at system level: define how delegated handlers in your codebase identify their subject so that re-rendering strategies — full replacement, keyed patching — cannot silently break event handling.

## Dispatch snapshots the path The DOM specifies dispatch in two steps: compute the event path from the target outward through its ancestors to `document` and `window`, then invoke listeners along that path. The computation happens once, at the start. Everything a listener does to the tree afterwards — removing nodes, moving them, replacing a container's contents — changes the document but not the itinerary the event is already following. That is why this sequence still works: ```javascript row.querySelector('.delete').addEventListener('click', () => row.remove()); document.addEventListener('click', (e) => { console.log('still runs', e.target.isConnected); // false }); ``` The delete button's handler runs during the bubble sweep, detaches the row (and the button with it), and the `document` listener runs anyway. ## What target keeps, and what it loses `event.target` is a strong reference to the node; it does not go null. `target.isConnected` becomes `false`, which is the reliable way to test "is this still in the document". What the node loses is its position relative to the rest of the page, and every API that reads position from the live tree loses with it: - `Element.closest(selector)` walks the element's current parent chain. If the whole row was detached in one piece, the button is *still inside* the detached row, so `closest('.row')` still finds it. If instead the container was re-rendered — `container.replaceChildren(...)`, or its markup reassigned — the clicked node has no parent at all and `closest()` returns `null`. - `document.contains(node)` and `node.getBoundingClientRect()` (all zeros for a detached node) go the same way. This is exactly why the bug is confusing: whether `closest()` survives depends on *how* the earlier handler mutated the DOM, not on whether it mutated it. ## composedPath is the recorded itinerary `event.composedPath()` returns an array of the objects the event travels through, ordered from the target outward and ending with `document` and `window`. It reflects the path computed at dispatch, so it is immune to later mutation. Scanning it is the mutation-proof way to answer "which row was this?": ```javascript document.addEventListener('click', (e) => { const row = e.composedPath().find( (n) => n instanceof Element && n.matches('.row') ); }); ``` Guard the `instanceof Element` check: the array's last entries are `document` and `window`, which have no `matches()`. ## Design consequences Once you know the path is fixed, three rules follow. **Resolve identity before mutating.** In a delegated handler, read what you need from the target — a `data-id`, the resolved row element — at the top of the handler, before any code re-renders. Deriving it afterwards is what fails. **Prefer stable data over live traversal.** A `data-id` read off the target survives detachment because it is copied out of the DOM. An ancestor lookup does not. **Order between handlers matters.** The inner handler runs first because bubbling is innermost-first, so a delegated `document` handler is *always* the one seeing the post-mutation world. If a container-level handler must see the pre-mutation DOM, register it for the capture phase, which visits the container before the target fires at all. ## A related edge worth knowing If a node is already detached *before* dispatch, the path is computed over its detached subtree: an event dispatched on it propagates up to the detached root and stops. It never reaches `document`, so delegated handlers never see it. That catches people who build a subtree in memory, dispatch on it, and wonder why nothing fired. ## Answering well Lead with the snapshot: the path is computed once, so propagation continues regardless. Then separate the two lookups — `closest()` re-derives from the live tree and can fail, `composedPath()` replays the recording and cannot. Finish with the practical rule: capture identity at the top of the handler, and prefer `data-` attributes over ancestor traversal in code that mutates.

  • How do you check whether the node in event.target is still in the document?
    Read `event.target.isConnected` — it is `true` while the node is attached to a document and `false` once detached. `document.contains(node)` answers the same question for the main document. `event.target` itself never becomes null, so testing for null is not a substitute.
  • What does composedPath() return as its final entries, and why does that trip people up?
    After the element chain it ends with `document` and `window`. Those are `EventTarget`s, not `Element`s, so calling `matches()` or reading `classList` on them throws. Any scan of the array needs an `instanceof Element` guard before treating an entry as an element.
  • What happens if you dispatch an event on a node that was never inserted into the document?
    The path is computed over its detached subtree, so the event propagates up to the detached root and stops there. It never reaches `document` or `window`, so delegated listeners registered on the document see nothing. Insert the subtree first, or attach the listener within it.

saying these in an interview costs you the question

  • Thinking removing the target cancels the rest of the dispatch
  • Expecting event.target to become null after detachment
  • Assuming closest() always still works because target is intact
  • Calling matches() on every composedPath entry without a type guard
  • Believing an event dispatched on a detached node reaches document

context