A click on a link sitting inside a card, itself inside a list, itself inside the page: the event does not concern the link alone. It travels through the whole lineage.
Understanding that trip explains most of the surprising behavior of an interface, from the menu closing too early to the form submitted twice.
Definition
Click inside a box to aim at it, then check capture without changing anything else.
event.target equals a on every line that runs. Only event.currentTarget changes: it equals the element of the line.
3 listeners called out of 4. After #list the run stops, and the event never reaches page.
The log is computed, not captured from real listeners: it applies the DOM rules to four nested elements. The box registers capture on every ancestor at once, where real code picks it listener by listener. Each box carries a single listener, which leaves aside stopImmediatePropagation, whose difference only shows when one element carries several.
Click a box, then turn capture on. The order of the ancestors flips, and that is the whole difference between the two listening modes.
Propagation is the trip an event takes through the document tree. It starts at the root, goes down to the element aimed at, then climbs back up to the root. Every listener met along the way is called.
| Phase | Direction | event.eventPhase |
|---|---|---|
| Capture | From the root toward the target | 1 |
| Target | On the target element itself | 2 |
| Bubbling | From the target back to the root | 3 |
By default, addEventListener() registers for the bubbling phase. Capture requires the capture option, and stays rare.
The order of the calls
const list = document.querySelector("#list");
const link = list.querySelector("a");
list.addEventListener("click", () => console.log("list, capture"), true);
link.addEventListener("click", () => console.log("link"));
list.addEventListener("click", () => console.log("list, bubbling"));
// A click on the link prints, in this order:
// list, capture
// link
// list, bubblingBubbling explains why a listener placed on a container fires although the click aimed at a child. That is not a flaw, it is the very mechanism Event delegation is built on.
target and currentTarget
Two properties answer two different questions, and mixing them up is the most frequent mistake in this area.
event.targetnames the element actually aimed at, often the deepest one in the tree.event.currentTargetnames the element the running listener was placed on.
Inside a classic function, this equals currentTarget. Inside an Arrow function it does not, so the explicit property is then the only safe route.
Stopping the run
link.addEventListener("click", (event) => {
event.stopPropagation(); // the list will never see it
console.log("handled here");
});stopPropagation keeps the event from traveling further up the tree, but lets the other listeners on the same element run. stopImmediatePropagation cuts those too. Neither of them cancels the default browser action, which belongs to preventDefault().
Stopping propagation out of convenience often breaks code written elsewhere: a menu closing on an outside click, a usage measurement, a global shortcut. Keep that move for the cases where it is genuinely needed.
Frequently asked questions
Do all events bubble?
No. focus, blur, mouseenter and mouseleave stay on their target. The first two have bubbling twins, focusin and focusout, designed for exactly this need. The event.bubbles property settles the question at run time.
What is the capture phase really for?
For stepping in before the aimed element, therefore before any listener placed lower down. It serves to intercept a gesture globally, for instance to block interactions during a load, or to catch an event that never bubbles.
Can the current phase be read?
Yes, event.eventPhase hands back 1, 2 or 3 depending on the phase under way. It is mostly a diagnostic tool: production code rarely branches on the phase, and relies instead on comparing target with currentTarget.