Synthetic Events in React — how do they differ from native DOM events
React wraps native DOM events in a cross-browser `SyntheticEvent` shim. Same API (`preventDefault`, `stopPropagation`, `target`) but normalized. React 17+ attaches listeners at the **root container**, not `document`; events bubble up through React's tree. Differences from native: no event pooling since React 17; `onChange` fires on every keystroke; `onScroll` doesn't bubble; some events use capture phase.
Synthetic Events are React's cross-browser event wrapper. They look like native events but with normalized behavior across browsers and an attachment model tuned to React's tree.
What a SyntheticEvent is
A thin wrapper around the native event with the same API:
preventDefault()stopPropagation()target,currentTargettype,bubbles,cancelablenativeEventfor the underlying native event
<button onClick={(e) => {
console.log(e.type); // 'click'
console.log(e.nativeEvent); // underlying native MouseEvent
}}>Click</button>Why React wraps events
Cross-browser normalization
Different browsers fire slightly different events with slightly different shapes. The wrapper smooths this over.
React-tree event flow
React attaches one delegated listener at the root container (in React 17+; was document before). Native events bubble to the root; React dispatches through the React component tree, which may differ from the DOM tree (portals).
This means React event handlers in a portal bubble up through the React parent even though the DOM portal node is elsewhere.
Performance via delegation
One listener at the root is cheaper than N per element. React handles delegation internally.
Differences from native events
1. Event pooling — gone in React 17+
Pre-17 React pooled SyntheticEvent objects for performance — the event you got was reused after the handler returned. Reading e.target later (e.g., in a setTimeout) gave null. You had to call e.persist().
In React 17+, pooling was removed. The event survives. e.persist() is a no-op.
2. Listener attached at root container, not document
Pre-17: document was the root listener target. 17+: the root container (<div id="root"> or similar).
This means multiple React trees on a page don't interfere with each other's events.
3. onChange fires on every keystroke
In native DOM, change on an <input> fires when the input loses focus. React's onChange is actually wired to native input — it fires on every keystroke. The native semantics are available via onChangeCapture and (more reliably) onInput.
4. onScroll doesn't bubble
Per the DOM spec, scroll doesn't bubble. React delegates events at the root, but scroll is special — handlers must be attached to the scrolling element itself.
5. Capture phase available
onClickCapture runs during capture phase (top-down) instead of bubble (bottom-up).
6. Some events not synthetic
window.onresize, native event listeners attached via addEventListener, etc. — those stay native.
Examples
Same as native
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>...</button>Portal bubbling through React tree
<Parent onClick={() => console.log("React parent")}>
{createPortal(<Child />, document.body)}
</Parent>
// Click inside <Child>: 'React parent' logs (React bubble),
// even though DOM-wise the portal isn't a descendant.Accessing the native event
<input onChange={(e) => {
console.log(e.target.value); // synthetic — works
console.log(e.nativeEvent.data); // native InputEvent property
}} />Pitfalls
Async access (pre-17 only)
// Pre-17 — bug
onClick={(e) => setTimeout(() => console.log(e.target), 100);} // null!
// Fix:
onClick={(e) => { e.persist(); setTimeout(() => console.log(e.target), 100); }}Not a concern in 17+, but legacy codebases may still have e.persist() calls.
Mixing native and React listeners
If you attach a native listener via addEventListener AND a React onClick, they fire at different points in the bubble. Be deliberate.
stopPropagation vs stopImmediatePropagation
Synthetic stopPropagation stops React-tree bubbling but doesn't stop native bubbling at the root. Rarely matters; flag if you're mixing libraries.
Interview framing
"React wraps native DOM events in a cross-browser SyntheticEvent shim — same API (preventDefault, stopPropagation, target) but normalized. From React 17 onward, the listener is attached at the root container (not document), and event pooling was removed (so e.persist() is no longer needed). The biggest non-obvious behaviors: onChange fires on every keystroke (it's wired to native input), onScroll doesn't bubble (attach to the scrolling element), and events in portals bubble through the React tree — not the DOM tree — meaning a portal child's click reaches its React parent. Access the underlying event via e.nativeEvent when you need it."
Follow-up questions
- •Why did event pooling exist pre-17 and why was it removed?
- •How does an event in a portal bubble?
- •What's the difference between onChange and onInput?
- •Why doesn't onScroll bubble?
Common mistakes
- •Using onChange expecting native 'change' (blur) semantics.
- •Old e.persist() calls left over from pre-17.
- •Mixing addEventListener with React handlers without understanding order.
- •Expecting scroll events to bubble.
Performance considerations
- •Delegation at root is cheap. Avoid attaching N handlers when one delegated handler with `event.target` would do.
Edge cases
- •Synthetic events not fired for some native events (resize, scroll on non-element).
- •Custom events from Web Components.
- •Synthetic stopPropagation not affecting native handlers at root.
Real-world examples
- •React DnD, drag-and-drop libraries handle the native/synthetic boundary.
- •Portal-based modals/popovers.