Back to React
React
easy
mid

How does event handling work in React, and how does it differ from native DOM events?

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`.

5 min read·~10 min to think through

React's event system has two interesting things going on: synthetic events and root-level delegation.

The basics.

tsx
<button onClick={handle}>Click</button>
  • camelCase event name (onClick, not onclick).
  • Function reference, not a string.
  • Handler receives a SyntheticEvent, not the raw DOM event.
tsx
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 gonee 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 onClick does NOT add 1000 native listeners. Cheap.
  • stopPropagation() on a native listener at document level 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 — synthetic onFocus/onBlur go through capture phase. onMouseEnter / onMouseLeave are simulated via tracking.

Common handlers.

tsx
<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 onScroll doesn't support { passive: true } directly. For high-frequency scroll handlers on mobile, use useEffect + 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 preventDefault synchronously — 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 than scroll / mutation listeners.

Stable handler identity (for memoization).

tsx
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.target vs e.currentTarget. target is the deepest element under the cursor; currentTarget is the element the handler is attached to. Use currentTarget for "the button I clicked," target for delegation patterns.
  • onClick on a <div> is a11y debt. Use <button> for actions, <a> for navigation. If you must use a div, add role="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 into useCallback or pass the id via the row component, not the handler.
  • Don't return false from handlers — that's jQuery. Call e.preventDefault() and/or e.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.nativeEvent gives 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.

Related questions