The event journey: capture vs. bubble
A click doesn't happen "at" the element you clicked — it travels down from window to that element and back up again, and every listener along the way gets a turn. This lesson walks that two-phase journey, untangles stopPropagation from preventDefault, and explains why React attaches almost no real DOM listeners at all.
The event journey: capture vs. bubble
You click a checkbox nested three <div>s deep. In your head, "the click happened on the checkbox" — one event, one element, done. That model is wrong, and it's wrong in a way that will eventually cost you a debugging session: a modal that closes itself the instant it opens, a table row's click handler firing before the button inside it gets to do its own thing, a <Link> navigating away when you only wanted to toggle a tooltip.
What actually happens is that the browser builds a straight line from the root of the document down to the element you clicked, and the event walks that entire line twice. The one governing fact for this whole lesson: every event has a path, not a point — and where your listener sits on that path, and which direction it's watching, determines whether it fires at all, and in what order relative to everyone else's.
Here's the map. We'll cover the two-phase path itself, the capture option that lets you watch the downward leg, the sharp distinction between stopping propagation and stopping a default action, and how React fakes all of this with one listener instead of thousands.
The path is real, and it's traversed twice
When you click that checkbox, the browser doesn't just ask "what did the user click?" and hand the answer to one listener. It first computes the full event path — the checkbox's ancestor chain, all the way up to document and window. Then it dispatches the event along that path in two passes:
- Capturing phase. The event starts at
window, thendocument, then walks down through every ancestor —<html>,<body>, your outer<div>, the middle one, the inner one — until it reaches the actual target, the checkbox itself. - Bubbling phase. Having reached the target, the event now reverses and walks up the exact same chain, from the checkbox back out to
window.
At every stop on both legs, the browser checks: did anyone register a listener here, for this event type, watching this phase? If so, it runs, synchronously, before the walk continues.
Here's the part that trips people up: element.addEventListener('click', fn) and JSX's onClick={fn} both register for the bubble phase only, by default. That's almost every listener anyone writes. The capturing phase is happening on every single event, on every element in the chain — you just haven't been listening to it. It's not a rare mode; it's the first half of a journey you've only ever seen the second half of.
Opting into the downward leg
The third argument to addEventListener controls which leg a listener watches:
outer.addEventListener(
'click',
(e) => console.log('outer saw it on the way down'),
{ capture: true } // or just `true`
);
outer.addEventListener(
'click',
(e) => console.log('outer saw it on the way up'),
// no options / { capture: false } — this is the default
);Why would you ever want the downward leg? Because capture gives an ancestor first refusal on an event before any descendant's own handler has had a chance to run — including a chance to call stopPropagation() and cut the walk short. A modal overlay that wants to log or veto every click inside it regardless of what the clicked child does; an analytics wrapper that needs to record a click even if some deeply nested component stops it from bubbling further; a drag-and-drop container that needs to see the pointerdown before the item underneath decides to handle it itself — all of these are jobs for a capture-phase listener on the outer element, because "outer, then inner" is exactly the order capture guarantees and bubble cannot.
Try this yourself before going further. Below are three nested boxes — Outer, Middle, Inner — wrapped around a real checkbox. Each box has both a capture-phase and a bubble-phase click handler wired up, and there are two toggles that change what the checkbox's own bubble handler does. Click the checkbox with both toggles off and watch the log: you should see six lines, capture running Outer → Middle → Inner, then bubble running Checkbox → Inner → Middle → Outer — the target's own handlers sit at the hinge between the two legs. Flip on "stopPropagation" and click again: the log now stops right after the checkbox's own bubble entry, because nothing further up the chain ever gets a turn. Flip on "preventDefault" instead (leave stopPropagation off) and notice the log runs in full — all six lines — but the checkbox no longer visually ticks, because you cancelled the browser's default action for the click without touching propagation at all. That last observation is the entire point of the next section.
Click the checkbox above and watch the order handlers fire in, live.
stopPropagation and preventDefault stop two different things
These two methods get reached for interchangeably by people who are annoyed that "the event didn't stop." They don't do the same job, and mixing them up either breaks a feature or leaves a bug that only shows up in a parent component you didn't even know was listening.
stopPropagation() stops the event from continuing to travel along its capture/bubble path to any other listener, on any other element. If you call it inside a bubble-phase handler on the checkbox, the walk simply never reaches Inner, Middle, Outer, or window — their handlers, capture or bubble, are skipped entirely for this event. What it does not do is touch the browser's own built-in behavior for that event. A click on a checkbox still toggles the checkbox; a click on an <a> still navigates. Propagation and default behavior are separate mechanisms, and stopping one says nothing about the other.
preventDefault() does the opposite job: it cancels whatever built-in action the browser was about to perform because of this event — following the link's href, submitting the enclosing form, ticking the checkbox, scrolling on a wheel event. It does not stop the event from continuing to propagate. Every ancestor listener, capture and bubble, still runs exactly as it would have.
A concrete case for wanting only preventDefault(): a custom-styled checkbox where you manage the checked state yourself in a click handler, but you still want a parent row's onClick (used for, say, "select this row") to fire — you cancel the browser's native toggle, but you deliberately let the click keep bubbling to the row.
A concrete case for wanting only stopPropagation(): a dropdown menu item inside a card that also has its own onClick (say, "open card details"). Clicking the menu item should do its own thing and must not also trigger the card's open handler — but the menu item's default action (there usually isn't a meaningful native one for a <div role="menuitem">) was never a concern in the first place.
React doesn't attach the listeners you think it does
Write <li onClick={handleClick}> inside a list of five thousand rows, and it feels like you asked for five thousand real DOM listeners. React doesn't do that. Instead, React attaches one real listener — historically at document, in modern React at the root container you rendered into — for each event type it needs to support, and lets the browser's own capture/bubble mechanism deliver every native event to that single root listener.
From there it's pure bookkeeping. React already knows, from rendering, the full tree of components between the root and whatever DOM node the native event landed on. When the root listener fires, React walks its own component tree along that same path — synthesizing its own capture pass and bubble pass in JavaScript — and calls every onClick (and onClickCapture) it finds along the way, in the same capture-then-bubble order the real DOM would have used, before handing you a SyntheticEvent that wraps the native one (and itself exposes stopPropagation/preventDefault, which React translates back into the equivalent effect on the underlying native event).
This exists purely for cost. Real DOM listeners aren't free — each one is memory and setup overhead, multiplied by every row, every button, every list item you render. One listener per event type at the root, plus dispatch that's just JavaScript function calls up and down an array, is dramatically cheaper at scale, and it's also why React can attach and detach that dispatch logic without ever touching the DOM's own listener list as components mount and unmount.
The capture phase doesn't disappear in this model — React just exposes it under its own naming convention. onClickCapture (and the general onXCapture pattern for any event) is React's way of registering a handler that runs during React's simulated capture pass, exactly mirroring what { capture: true } does for a real addEventListener call. If you've ever wondered why React's props include both onClick and onClickCapture, it's the same two-phase journey from the top of this lesson, replayed one layer up, in userspace.
One naming collision worth flagging before you go looking for related material: the next lesson, Pointer events and setPointerCapture, covers pointer capture — an unrelated mechanism where an element temporarily claims all pointer events regardless of where the cursor moves (used for things like custom sliders). It shares the word "capture" with this lesson's capturing phase purely by coincidence of terminology; don't let the name fool you into thinking they're the same feature. If you're chasing down effect-cleanup bugs where listeners seem to fire more or fewer times than expected, useEffect discipline picks up a closely related thread.
Go deeper
- MDN — EventTarget.addEventListener() — The authoritative reference for the capture option and the exact three-argument signature that controls which phase a listener runs in.
- MDN — Event.stopPropagation() — Precisely what stopPropagation halts (and, just as important, what it does not) — the disambiguation this lesson leans on.
- MDN — Event.preventDefault() — The companion method, and why it's independent of propagation — cancelling a browser default is a different axis from stopping a listener chain.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- You register two listeners on the same button, one with { capture: true } and one with no options at all, both for 'click'. In what order do they fire, and why does the target element's phase order matter here?
- A click handler on an outer container calls stopPropagation() during the capture phase. What happens to a bubble-phase listener you registered directly on the element the user actually clicked?
- Why does calling preventDefault() inside a form's submit handler stop the page from navigating, but do nothing to stop a parent element's own click listener from running afterward?
- You want an analytics wrapper around a whole page to record every click even if a component deep inside calls stopPropagation() in its own bubble handler. Which phase should the wrapper's listener use, and why would the bubble phase fail here?
- Explain, without using the word 'magic', how React runs your onClick handler on a <li> inside a 10,000-row list without attaching 10,000 real DOM listeners.
- What does onClickCapture map to in terms of the real browser mechanism this lesson describes, and when would you reach for it over plain onClick?
- A teammate says 'pointer capture and event capturing are basically the same thing, right?' Where's the flaw in that statement?