Free lesson · React Deep Dive
Synthetic Events: How React Intercepts DOM Events
Lesson 7.1 - Synthetic Events: How React Intercepts DOM Events
When you write <button onClick={handleClick}> in React, you might assume - reasonably - that React is just calling button.addEventListener('click', handleClick) under the hood. That's almost what's happening, but not quite.
What React actually does is more interesting and worth understanding. React maintains its own event system, parallel to (but coordinated with) the browser's native event system. When you write onClick, you're not adding a listener to the button. You're telling React that a click on that button should trigger your handler - and React handles the actual DOM event listening at a different level, then dispatches into your handler when appropriate.
This lesson covers what that system is, why it exists, how it works in modern React (it changed in React 17), and the practical implications. By the end, you'll understand what an event handler actually receives, when to reach for the native event, and the edge cases that arise from React's event delegation.
The Synthetic Event
When your handler runs, the argument it receives isn't a native DOM event. It's a synthetic event - React's wrapper that provides a consistent cross-browser API.
function Button() {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e) // a SyntheticEvent
console.log(e.nativeEvent) // the underlying native MouseEvent
console.log(e.target) // the element that received the click
console.log(e.currentTarget) // the element the handler is attached to
}
return <button onClick={handleClick}>Click</button>
}
The synthetic event has the same interface as the native event - preventDefault(), stopPropagation(), target, currentTarget, all the usual properties. For most practical purposes, you can treat it as if it were the native event. React wraps the native event to give you a stable API and to handle a few cross-browser quirks consistently.
If you ever need the actual native event - to access browser-specific properties, to integrate with non-React code that expects native events - it's available via e.nativeEvent. In day-to-day work, you'll rarely need it.
A historical note worth mentioning: React 15 and earlier used to pool synthetic events for performance - after your handler ran, React would reset the event object and reuse it for the next event. This meant you couldn't store references to synthetic events for later use without calling e.persist(). Event pooling was removed in React 17, and modern code doesn't need to think about it. If you encounter e.persist() in older codebases or tutorials, that's why; modern code doesn't need it.
Event Delegation at the Root
The mechanism beneath the synthetic event system is event delegation. Instead of attaching listeners to every element with handlers, React attaches a small set of listeners to the root container and uses event propagation to figure out which handler should fire.
flowchart TB
User["User clicks button"]
DOM["Browser fires native click event<br/>on the button"]
Bubble["Event bubbles up through DOM"]
Root["Reaches React's root container"]
Listener["React's delegated listener<br/>catches the event"]
Lookup["React looks up which handler<br/>matches the target's onClick prop"]
Dispatch["React calls your handler<br/>with a SyntheticEvent"]
User --> DOM --> Bubble --> Root --> Listener --> Lookup --> Dispatch
style Listener fill:#a78bfa,stroke:#8b5cf6,color:#000
style Dispatch fill:#34d399,stroke:#10b981,color:#000
A diagram of how React's event delegation works. The user clicks a button in your UI. The browser fires a native click event on that button. The event bubbles up through the DOM tree. It reaches React's root container - the element you mounted React into. React has a delegated listener registered there that catches all events of that type. React looks up which component's onClick prop matches the target element. React then calls your handler function, passing a SyntheticEvent that wraps the native event. From your perspective, your handler "fired on the button," but mechanically, React's root listener fired and dispatched into your handler.
The practical implication is that React's handlers aren't attached to your individual elements. They're attached to the root. React's event system maintains a mapping of "which elements have which handlers" and dispatches into your code when events arrive at the root.
Why does React do this?
Memory efficiency. A list of 10,000 rows with onClick on each row doesn't create 10,000 listeners. There's one listener at the root that handles all of them via delegation.
Adding and removing handlers is cheap. When a component mounts with onClick, React just updates its internal map. It doesn't actually call addEventListener for that handler. When the component unmounts, removing the handler is similarly cheap - just an internal map update.
Consistent behavior across components. React can apply its own logic (synthetic event normalization, the rules for which props correspond to which events) uniformly because all events flow through its system.
What Changed in React 17
A specific historical detail that matters because it affects how React interacts with other code on the page.
Before React 17, React's delegated listeners were attached at document. This meant:
- Multiple React apps on the same page would share listeners at
documentand could conflict. - Native event listeners attached to specific DOM elements would fire before React's delegated handlers, which sometimes confused integration with third-party libraries.
- Calling
e.stopPropagation()on a native event listener could prevent React's handlers from firing, even if those handlers were on elements "above" the listener in the visual hierarchy.
Starting with React 17, delegated listeners are attached at the root container - the element you mount React into (typically <div id="root">). This means:
- Multiple React apps on the same page work cleanly; each has its own root with its own delegation.
- Native event listeners on elements outside React's root don't interact with React's events.
- The behavior matches developer intuition more closely.
If you're working with a modern Vite + React setup (which this course uses), you're on React 17+ behavior. The document-level delegation is historical. But knowing it changed helps you understand older codebases and tutorials that mention "event delegation at document."
Bubbling and the Capture Phase
React events follow the same bubbling rules as native DOM events. An event fires at the target, then propagates up through ancestor elements. If multiple ancestors have handlers for that event, they all fire (in order from target to root) unless propagation is stopped.
function Outer() {
return (
<div onClick={() => console.log('outer')}>
<div onClick={() => console.log('middle')}>
<button onClick={() => console.log('inner')}>Click</button>
</div>
</div>
)
}
Clicking the button logs:
inner
middle
outer
The event fires at the button (where the click happened), then bubbles up. Each ancestor's onClick fires in order.
To stop propagation, call e.stopPropagation():
const handleInner = (e: React.MouseEvent) => {
e.stopPropagation()
console.log('inner - propagation stopped')
}
Now only "inner" logs. The middle and outer handlers don't fire because propagation stopped at the inner button.
React also supports the capture phase, where handlers fire on the way down (root to target) instead of on the way up. You use this by adding Capture to the event name:
<div onClickCapture={() => console.log('captured')}>
<button onClick={() => console.log('clicked')}>Click</button>
</div>
This logs:
captured
clicked
Capture handlers fire in the capture phase (root → target). Regular handlers fire in the bubble phase (target → root). For most UI work, you only need bubble; capture is useful for things like "intercept all clicks below this element" patterns.
A Note on stopPropagation and Native Events
A subtle case worth knowing about. e.stopPropagation() in a React handler stops propagation within React's synthetic event system. It doesn't stop the underlying native event.
This matters if you have native event listeners on the same page - say, you've attached document.addEventListener('click', ...) for some reason. That native listener will still fire even if a React handler called stopPropagation(), because the native event continues to bubble at the DOM level even though React stopped dispatching it within its own system.
If you need to stop the native event too, use e.nativeEvent.stopPropagation():
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation() // stops React handlers from firing
e.nativeEvent.stopPropagation() // stops native handlers from firing
}
In day-to-day React work, you rarely need this. It comes up when integrating with third-party libraries that attach native listeners directly, or when working with portals and modals where event flow gets nuanced. The takeaway: React's event system is its own world that's coordinated with - but not identical to - the native DOM event system.
Portals: Where the Tree Diverges from the DOM
A specific consequence of the synthetic event system that's worth knowing. React events bubble through the React tree, not the DOM tree. Most of the time these are the same, but when you use a portal (covered in Module 9), they diverge.
A portal renders a child somewhere else in the DOM - typically document.body for modals - while the child remains logically in its React parent's tree.
function App() {
return (
<div onClick={() => console.log('App clicked')}>
<button>Open Modal</button>
<ModalPortal>
<button>Inside modal</button>
</ModalPortal>
</div>
)
}
If ModalPortal renders into document.body, the modal button is physically a sibling of <div id="root"> in the DOM. But in the React tree, it's a child of App. Clicking the modal button logs "App clicked" - the React event bubbles through the React tree, which still has App as an ancestor.
This is usually what you want - a portal's contents feel "inside" their logical parent for event purposes, even though they're rendered elsewhere in the DOM. Without this behavior, modals and tooltips would lose connection to the rest of the app's event flow.
The implication: when reasoning about React events, think about the React tree, not the DOM tree. They usually match, but portals (and a few other advanced cases) are exceptions.
The Event Types in TypeScript
A practical note for TypeScript users. React provides specific event types for each event:
// Mouse events
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { /* ... */ }
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => { /* ... */ }
// Keyboard events
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { /* ... */ }
// Change events (inputs)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { /* ... */ }
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { /* ... */ }
// Focus events
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => { /* ... */ }
The generic parameter is the element type. It tells TypeScript what e.target and e.currentTarget are. Without it, you'd get a generic Element and lose the typed access to properties like e.target.value.
The good news: when you write handlers inline as JSX attributes, TypeScript often infers the correct type:
<button onClick={(e) => { /* e is typed correctly here */ }}>
Click
</button>
You only need to write the explicit type when you extract the handler into a named function above the JSX. The pattern: inline handlers get inference; extracted handlers need explicit types.
We'll cover TypeScript-with-React more in Module 24. For this module, the event types above cover most cases.
A Note on onChange vs Native change
A specific quirk worth knowing. The native HTML change event on an <input> fires only when the input loses focus (blur), not while typing. Many engineers expect change to fire on every keystroke; in the DOM, it doesn't.
React's onChange does fire on every keystroke. React effectively maps onChange to the native input event (which fires on every change to the value), not the native change event. This was a deliberate decision - beginners almost always want "fire on every change," and React picked the more useful default.
This means onChange in React is what oninput is in vanilla DOM, more or less. If you actually need the "fires only on blur" behavior, use onBlur separately or check for blur events explicitly.
This is one of the very few places React deliberately diverges from native event behavior. Most React events match native; onChange is the exception.
What I Want You to Take From This Lesson
Six things.
One: React's synthetic event system wraps native events. Consistent API, normalizes cross-browser quirks, provides a layer for React's optimizations.
Two: Event delegation at the root, not on individual elements. Memory efficient; one listener handles all clicks.
Three: React 17+ delegates at the root container, not at document. Older codebases (and tutorials) may reference the document-level behavior.
Four: Synthetic events bubble through the React tree, not the DOM tree. Matters for portals and similar advanced cases.
Five: e.stopPropagation() stops React's dispatch; e.nativeEvent.stopPropagation() stops native propagation. Different layers.
Six: onChange in React fires on every keystroke (matching native input), not on blur. Deliberate divergence.
Try It
Exercise: Warmup (15 minutes). Build a component with nested
onClickhandlers -<div><div><button>each with handlers. Click the button; verify the bubbling order. AddstopPropagationto the inner button; verify only "inner" logs.
Exercise: Core (20 minutes). Inspect a React app's DOM in the browser DevTools. Find your buttons. Look at their event listeners (Elements panel → "Event Listeners" tab). Notice there's no listener on the button itself - the listeners are at the root.
Exercise: Core (20 minutes). Build a TypeScript event handler for an input. Use
React.ChangeEvent<HTMLInputElement>. Reade.target.value. Verify the type matches.
Exercise: Stretch (30 minutes). Read React docs - Responding to Events carefully. Note specifically the event types they list and the patterns for common cases.
What's Next
Lesson 7.2 covers handler patterns and the common mistakes - calling vs passing, arguments via closures, preventDefault, stopPropagation done right.