Prompt and Applicable Context
A task list can display thousands of rows. Pagination results, live updates, and user actions insert or remove rows dynamically. Each row has edit, delete, and status-toggle buttons, and a button can contain an icon or text element. The list may also contain an independently owned nested subtask list. Register only one click listener on the outer list and dispatch an action using the control's data-action and its row's data-task-id.
The answer must explain how an event travels from ancestors to its target and back toward ancestors, distinguish event.target from event.currentTarget, and keep the outer list from handling actions owned by the nested list. It must also cover events that cannot use ordinary bubbling delegation, the effect of a descendant stopping propagation, how Shadow DOM changes the visible path, and how the listener is removed when the component unmounts.
This question fits mid-level frontend, Web platform, and full-stack interviews. Its core skill is browser DOM event dispatch and UI ownership boundaries, so it belongs to frontend. It does not test a userland publish-subscribe container or task, microtask, and rendering scheduling.
What the Interviewer Is Evaluating
Strong answers begin with an accurate model. Dispatch follows an event path through the capture phase, the target phase, and, when the event can bubble, the bubble phase. addEventListener registers a bubbling listener by default; capture: true makes it run while the event travels toward the target. Saying only that “events bubble up” cannot explain capture listeners, non-bubbling events, or Shadow DOM boundaries.
Implementation quality appears in the routing logic. target identifies the event's dispatch target; currentTarget identifies the object whose listener is currently running. In a parent list handler, the former might be an icon inside a button while the latter is the list. A tag-name comparison misses nested markup. A robust answer starts at target, uses closest() to find an action control, and then proves that the control belongs to the active delegation root.
Ownership boundaries separate a basic answer from a production-ready one. contains() can reject a match outside the container, but the container may itself contain another independently delegated root. Requiring the nearest delegation root to equal the current list expresses ownership and prevents both outer and nested lists from executing one action. Action names should also go through an allowlist rather than becoming arbitrary function names from DOM strings.
The verification plan should target observable failures: nested markup, dynamically added rows, disabled buttons, nested roots, stopped propagation, focus events, Shadow DOM, asynchronous callbacks, and unmount cleanup. Delegation reduces listener count and naturally covers dynamic descendants, but a small stable interface may be clearer with direct listeners. Delegation is a design choice, not a mandatory optimization.
Questions to Clarify Before Answering
- Which events are delegated?
clicknormally bubbles and fits this prompt. Delegatingfocus,
blur, mouseenter, or mouseleave requires capture, a bubbling alternative, or direct listeners; that choice changes both implementation and semantics.
- Can the list contain nested components or Shadow DOM?
contains()is often enough for a flat
list. Nested ownership needs a separate root marker, while Shadow DOM needs a public component-event contract.
- Is button markup stable? If the target can be an icon or text node, the handler needs
closest(). matches() is sufficient only when the target is guaranteed to be the action element.
- Who may call
stopPropagation()? If a child component is allowed to stop propagation, an
ancestor bubble listener cannot promise to see the event. Clarify ownership instead of silently moving business actions into capture.
- How is disabled state represented? Native buttons use
disabled. A custom control also needs
accessible and keyboard behavior; a CSS class alone is not a reliable contract for the handler.
- Who owns listener lifetime? A page-level root may be permanent. A mountable component must retain
a removable function reference or use AbortSignal, or remounting will process one click multiple times.
- Does an action need to cancel default behavior? Call
preventDefault()only when navigation,
form submission, or another default action conflicts with product intent. Default cancellation and propagation control are independent decisions.
30-Second Answer Framework
“An event is captured down its path, handled at the target, and bubbles back through ancestors when bubbles is true. A parent's default listener runs in the bubble phase, so one listener can cover dynamic descendants. In the handler I first verify that target is an Element, then call closest('[data-action]'). I check contains and require the nearest delegation root to be the current list so a nested list cannot leak actions outward. currentTarget is the list with the listener and is reliable only while the handler runs. I dispatch through an action allowlist and clean up with an AbortController. For non-bubbling events, stopPropagation, and Shadow DOM, I choose capture, a bubbling alternative, a direct listener, or a component-level custom event according to the boundary, then test nested targets, dynamic rows, and unmount/remount.”
Step-by-Step Deep Dive
Start with the event path. Suppose a click begins on an icon inside a button. The path includes the document, outer list, task row, button, and icon. Capture listeners run from the outer part of the path toward the target. Applicable target listeners run when the event reaches the target. If bubbles is true, ancestor bubble listeners then run from the inside outward. The dispatch algorithm builds this path, and both DOM structure and Shadow DOM boundaries affect it.
target and currentTarget answer different questions. target asks where this interaction was dispatched and normally remains stable during ordinary DOM bubbling. currentTarget asks which object's listener is running now; it changes between handlers and becomes null after the handler returns. Save any required list reference or data synchronously. Do not rely on event.currentTarget after an await.
The following implementation separates matching, ownership, state, and action dispatch. Assume the list root has data-delegation-root, each row has data-task-id, and actions use native buttons with data-action. openEditor, deleteTask, and toggleTask stand for existing product functions:
const list = document.querySelector("#task-list");
if (!(list instanceof HTMLElement)) {
throw new Error("task list was not found");
}
const handlers = new Map([
["edit", (taskId) => openEditor(taskId)],
["delete", (taskId) => deleteTask(taskId)],
["toggle", (taskId) => toggleTask(taskId)],
]);
const controller = new AbortController();
list.addEventListener(
"click",
(event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const action = target.closest("[data-action]");
if (!(action instanceof HTMLButtonElement)) return;
if (!list.contains(action)) return;
if (action.closest("[data-delegation-root]") !== list) return;
if (action.disabled) return;
const row = action.closest("[data-task-id]");
if (!(row instanceof HTMLElement) || !list.contains(row)) return;
const actionName = action.dataset.action;
const taskId = row.dataset.taskId;
if (!actionName || !taskId) return;
const handler = handlers.get(actionName);
if (!handler) return;
handler(taskId);
},
{ signal: controller.signal },
);
function cleanupTaskListDelegation() {
controller.abort();
}target.closest() walks from the actual icon to its button, so internal markup changes do not break routing. list.contains(action) proves the match is still within the list. The nearest-root check then rejects buttons owned by a nested list. The Map permits only three known actions; an unknown data-action exits safely. The explicit disabled check documents the handler contract, so a disabled action is still rejected if the code later uses programmatic dispatch or changes its markup.
Propagation controls need separate explanations. stopPropagation() stops further travel through the capture or bubble path. It does not stop a default action or other listeners on the same element; the latter requires stopImmediatePropagation(). preventDefault() cancels the default action of a cancelable event but does not stop propagation. Treating these three APIs as equivalent leads to links still navigating, ancestors missing events, or same-node handlers unexpectedly continuing.
Choose a strategy for each non-bubbling event. An ancestor can observe focus and blur during capture, or use the bubbling focusin and focusout events. mouseenter and mouseleave can be bound directly. Replacing them with bubbling mouseover and mouseout also requires relatedTarget filtering because movement between descendants creates extra events. A scrollable element is often clearest with a direct listener. Similar event names do not guarantee identical semantics.
Shadow DOM is a component boundary; document-level delegation should not depend on selectors inside a component. User-agent UI events such as click are generally composed across a shadow boundary, but an outside listener can see a retargeted host as target. composedPath() can expose the path through an open shadow root but does not expose nodes inside a closed root. A Web Component that needs to publish an action should handle internals itself and emit a documented component event that is allowed to bubble and cross the boundary. Consumers then depend on a stable public contract.
Verify behavior rather than merely counting listeners. Log capture, target, and bubble order; click both button text and its icon; add a row and click it without rebinding; remove a row and ensure its old node no longer acts; click a nested list and ensure only its owner handles it; test disabled and unknown actions; let a child stop propagation and observe the missing ancestor event; cover the focus strategy and Shadow DOM; call cleanupTaskListDelegation() and prove clicks stop, then remount once and prove one click is handled exactly once.
High-Quality Sample Answer
“I would first confirm that we are delegating a bubbling click event. The browser captures along the event path to the target, handles the target, and then returns through ancestors when bubbling is allowed. A parent's default listener runs during bubbling. event.target is the dispatch target and may be an icon inside a button; event.currentTarget is the list with this handler and is valid only while the handler runs.
I start from target with closest('[data-action]'), then verify that the result is a button inside the current list. Because this list may contain another delegated component, I also require the action's nearest data-delegation-root to be this list. I find its data-task-id row and use a Map allowlist to dispatch edit, delete, or toggle to existing functions. Newly inserted rows are naturally covered by the same ancestor listener, and an AbortController removes it on unmount.
I would not force every event into bubble delegation. Focus can use capture or focusin, and mouseenter does not have the same semantics as mouseover. A child calling stopPropagation prevents the ancestor bubble handler from seeing the event. Across Shadow DOM, target may be retargeted and a closed root does not expose its internal path, so a component should publish a stable external event instead of making the page depend on internal selectors.
My tests cover an inner icon, dynamic rows, nested roots, disabled and unknown actions, stopped propagation, Shadow DOM, and unmount/remount. If there are only a few stable buttons with unrelated behavior, I use direct listeners. Delegation's main value here is a clear ownership boundary, dynamic descendant support, and one lifecycle—not a context-free performance claim.”
Common Mistakes
- Check only
event.target.matches('button')→ an icon click makes target the icon →
walk to the action element with closest().
- Execute the first
closest()match → it may belong to another container or nested root →
check both contains() and the nearest ownership root.
- Treat
currentTargetas the clicked element → in parent delegation it is the list with the
running handler → use target for the source and currentTarget for the handling boundary.
- Read
event.currentTargetafterawait→ the handler has returned and the value isnull→
save required element references and data synchronously.
- Assume
preventDefault()stops bubbling → a default may be canceled while ancestors still run →
decide default cancellation and propagation separately.
- Apply one delegation template to every event → focus and mouseenter do not bubble like click →
choose capture, an alternative event, or direct binding from event semantics.
- Inspect Shadow DOM internals from the document root → target is retargeted and a closed root hides
its internal path → let the component own internals and publish a stable event.
- Add anonymous listeners on every mount without cleanup → one click runs repeatedly with stale
state → retain a removable reference or manage lifetime with AbortSignal.
- Claim delegation is always faster → a small stable interface gains routing and filtering
complexity → choose from node dynamics, ownership, and maintenance cost.
Follow-ups and Responses
Follow-up 1: A child button calls stopPropagation, but outer delegation must still work. What do you do?
First decide who owns click semantics. If the child has no right to block the event, remove that call and repair the component contract. A capture listener can observe the event before it reaches the button, but executing deletion or another business action during capture runs before target handlers and default behavior, changing order and cancellation opportunities. Use capture takeover only when the product explicitly requires it, with ordering, cancellation, and duplicate-action tests.
Follow-up 2: A form needs centralized focus handling. How do you delegate it?
focus does not bubble, but an ancestor can observe it with capture: true; alternatively use the bubbling focusin event. If the goal is one field-help handler, the focusin model is often easier to reason about. Test keyboard, pointer, and programmatic focus, and avoid reporting every move within a composite widget as leaving that entire widget.
Follow-up 3: The action button is inside a closed shadow root. How does the page-level list identify it?
The page should not identify a button inside a closed root. The component handles click internally and publishes a semantic event from its host, with only a stable action and task identifier in its data and an explicit contract allowing bubbling and crossing the shadow boundary. The page delegates that component event. Encapsulation remains intact, and the page does not depend on internal nodes that composedPath() will not reveal.
Follow-up 4: The handler awaits confirmation. How do you avoid acting on the wrong row after DOM changes?
Read and validate the immutable taskId and actionName synchronously before starting the asynchronous flow. After confirmation, look up current business state by taskId; do not keep an old row reference and assume it is still connected. If duplicates matter, also mark the task/action as pending or use an idempotency key for the request.
Follow-up 5: When should you abandon delegation?
Direct listeners are easier to audit when nodes are few and stable, controls have unrelated behavior, the event does not bubble, or a component boundary requires local handling. For a high-frequency event, if one root repeatedly runs complex selectors for many irrelevant events, narrow the root or bind directly. Measure the actual performance path before changing structure for performance.