Event handling in React
React wraps native DOM events in a SyntheticEvent for cross-browser consistency. Attach handlers as JSX props (`onClick`, `onChange`) — camelCase, function reference, not a string. Since React 17, events are delegated to the React root, not `document`. Call `e.preventDefault()` / `e.stopPropagation()` on the synthetic event. For some events React doesn't expose (focus visible, native scroll passive listeners), use `addEventListener` in a `useEffect`.
React's event system has two interesting things going on: synthetic events and root-level delegation.
The basics.
<button onClick={handle}>Click</button>- camelCase event name (
onClick, notonclick). - Function reference, not a string.
- Handler receives a
SyntheticEvent, not the raw DOM event.
function handle(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
e.stopPropagation();
console.log(e.nativeEvent); // raw DOM event if you need it
}SyntheticEvent. A thin cross-browser wrapper. Same API (target, currentTarget, preventDefault, stopPropagation) regardless of browser. Pre-React 17, events were pooled (reused across handlers); accessing them async required e.persist(). Since React 17, pooling is gone — e is yours to keep.
Delegation: events attach to the root, not the element.
When you write <button onClick={...}>, React doesn't actually add a click listener to that button. It maintains a single delegated listener on the React root (in React 17+; on document pre-17) and dispatches to the right handler based on the event target.
Implications:
- Adding/removing 1000 buttons with
onClickdoes NOT add 1000 native listeners. Cheap. stopPropagation()on a native listener atdocumentlevel can prevent React's delegated handler from ever firing. Order matters.- For events that bubble (most) this works. For events that don't bubble (
focus,blur,scroll,mouseenter/mouseleave) React uses workarounds — syntheticonFocus/onBlurgo through capture phase.onMouseEnter/onMouseLeaveare simulated via tracking.
Common handlers.
<input onChange={e => setVal(e.target.value)} /> // not onInput
<form onSubmit={e => { e.preventDefault(); ... }} />
<a onClick={e => { e.preventDefault(); navigate(...); }} />
<div onKeyDown={e => { if (e.key === "Enter") ...; }} />onChange in React fires on every keystroke (unlike the native DOM change which fires on blur). React treats onChange and onInput as the same for inputs.
When to bypass React's event system.
- Native passive scroll listeners. React's synthetic
onScrolldoesn't support{ passive: true }directly. For high-frequency scroll handlers on mobile, useuseEffect+addEventListener("scroll", h, { passive: true }). - Events outside the React tree. Document/window-level shortcuts:
useEffect(() => { document.addEventListener("keydown", ...); return () => ... }, []). - Wheel and touch events that need to call
preventDefaultsynchronously — Chrome warns when synthetic handlers do this. Use a native non-passive listener. ResizeObserver,IntersectionObserver,MutationObserver— not events but the modern equivalents for layout/visibility/DOM changes; far cheaper thanscroll/mutationlisteners.
Stable handler identity (for memoization).
const onClick = useCallback(() => save(id), [id]);Inline arrow functions create a new reference each render. For children wrapped in React.memo, that breaks the bailout. (React 19+ compiler handles this automatically.)
Common gotchas.
e.targetvse.currentTarget.targetis the deepest element under the cursor;currentTargetis the element the handler is attached to. UsecurrentTargetfor "the button I clicked,"targetfor delegation patterns.onClickon a<div>is a11y debt. Use<button>for actions,<a>for navigation. If you must use a div, addrole="button",tabIndex={0}, and a keyboard handler.- Passing args to handlers.
onClick={() => save(id)}allocates a new closure each render. For memo children, lift the binding intouseCallbackor pass the id via the row component, not the handler. - Don't return false from handlers — that's jQuery. Call
e.preventDefault()and/ore.stopPropagation().
SyntheticEvent vs native — when does it matter?
- 99% of the time, treat them the same.
- Mobile gesture libraries (PointerEvent, custom touch sequences) typically prefer native listeners.
- For pixel-perfect coordinate math in fast pointer movements,
e.nativeEventgives you the unwrapped event.
Senior framing. Two ideas: synthetic events for cross-browser portability, root delegation for performance. Plus the practical lane: use native listeners when you need passive scroll, document-level listeners, or precise pointer math.
Follow-up questions
- •How does React 17's delegation differ from pre-17?
- •Why might React's onScroll be insufficient on mobile?
- •When do you need `e.nativeEvent`?
- •Why is event pooling gone in React 17+?
Common mistakes
- •Using `onclick` (lowercase) or passing a string handler.
- •Calling `setState` inside a handler that also calls `e.preventDefault` after an async await without persisting the event (pre-17).
- •Adding `onClick` to a `<div>` without a11y fallbacks.
- •Confusing `e.target` and `e.currentTarget`.
Performance considerations
- •Delegation makes adding many event-handling children cheap.
- •Inline arrow functions break memoization; use useCallback for shared handlers.
Edge cases
- •Synthetic onScroll passive default — can't preventDefault synchronously on mobile wheel/touch.
- •Custom elements / web components — React's synthetic onChange doesn't fire; use addEventListener.
- •Portals — events bubble to React parent, not DOM parent.
Real-world examples
- •Global keyboard shortcuts via document listener in effect; passive scroll for sticky headers.