How does React handle event delegation
Since React 17, React attaches one listener per event type at the **app root** (the container you passed to `createRoot`), not at `document`. Events bubble up; React's synthetic event system reconstructs the event, walks the React tree, and fires the appropriate `onClick` handlers. `onClickCapture` runs in capture phase. `stopPropagation` on a synthetic event only stops React listeners, not native ones.
What React does
You write <button onClick={fn}>. React doesn't call button.addEventListener("click", fn). Instead:
- At app mount, React attaches one listener per event type to the root container (since v17; was
documentin v16). - When an event fires, the listener catches it, constructs a synthetic event, walks the React fiber tree from the target to the root, and invokes matching handlers (bubble phase by default, capture for
onClickCapture). event.stopPropagation()on the synthetic event halts further React handlers in the chain. Native listeners on actual DOM nodes are unaffected.
Why delegate
- Memory + setup — one listener vs one per element.
- Dynamic children — added/removed without re-binding.
- Consistent event handling across browsers (synthetic event normalization).
React 16 vs 17+
- React 16: listeners on
document. Multiple React apps on the same page could conflict; native code higher up couldn't reliably stopPropagation. - React 17+: listeners on the root container. Lets multiple React versions coexist; allows native code in ancestors to intercept.
This was a significant change for incremental migration (microfrontends, gradual React adoption).
Synthetic events
Wraps native events with a normalized API:
function onClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
// e.nativeEvent for the raw DOM event
}Pre-17, synthetic events were pooled for perf (e reused). Post-17 pooling was removed — you can hold onto events across async.
Capture phase
<div onClickCapture={fn}>...</div>Runs during capture phase (parent → target), before bubble handlers fire.
stopPropagation gotcha
function Modal() {
return (
<div onClick={() => close()}> {/* backdrop closes */}
<div onClick={(e) => e.stopPropagation()}> {/* clicking inside doesn't close */}
...
</div>
</div>
);
}Works for React listeners. But if a non-React library attached a click listener on document or window, e.stopPropagation() on the synthetic event doesn't stop the native event from bubbling up — they're separate.
To stop both: e.nativeEvent.stopImmediatePropagation().
Touch / pointer / focus
- Many events still attached at root.
- Some events (focus/blur) don't bubble natively; React provides
onFocus/onBlurthat bubble via the synthetic system.
Performance implications
- Adding 10k onClick props in JSX has no per-row listener cost; it's just data in fiber.
- For very specific cases (drag tracking on a huge canvas), going direct via
ref.addEventListenercan be faster. - Outside React UI libraries that listen on document need explicit interop.
Interview framing
"Since React 17, React attaches a single listener per event type on the root container. onClick doesn't bind a DOM listener — it's a prop React reads when it walks the fiber tree from event target to root, invoking handlers in bubble (or capture for onClickCapture). The root-level delegation means there's effectively zero listener cost per row, even on huge lists. The non-obvious bit: e.stopPropagation() on a synthetic event only stops other React handlers — native listeners outside React still fire. Use e.nativeEvent.stopImmediatePropagation() if you need to stop both. The v16 → v17 change from document to root listeners was specifically to support multiple React versions and incremental migration."
Follow-up questions
- •Why move listeners from document to root in v17?
- •What does e.nativeEvent give you?
- •How does this interact with native event libraries?
Common mistakes
- •Assuming React's stopPropagation stops native bubbles.
- •Mixing native and React listeners without understanding ordering.
- •Using onClick on a div instead of a button.
Performance considerations
- •Effectively zero per-row listener cost. The fiber-walk is O(depth).
Edge cases
- •Multiple React apps on the same page (v16 issues, v17 fix).
- •Native libraries with document listeners + React modals.
- •Shadow DOM + React.
Real-world examples
- •React 17 release notes, Radix dismissable-layer's nativeEvent usage.