Event Handling and Synthetic Events
React Events Are Not DOM Events
When you write onClick={handler} in React, you are not adding an onclick attribute or calling addEventListener on that DOM element. React uses event delegation — it attaches a single listener to the root of your application and dispatches events to the correct component handler.
function Button() {
function handleClick(event) {
// event is a SyntheticEvent, not a native MouseEvent
console.log(event.constructor.name); // SyntheticBaseEvent
console.log(event.nativeEvent.constructor.name); // MouseEvent
}
return <button onClick={handleClick}>Click me</button>;
}
Think of React's event system as a receptionist at a large office building. Instead of every office (DOM node) having its own doorbell, there is one receptionist at the front desk (root container). When a visitor (event) arrives, the receptionist checks the directory (fiber tree), finds the right office (component), and routes the visitor there. The receptionist also translates the visitor's message into a standardized format (SyntheticEvent) so every office handles visitors the same way.
Event Delegation: How It Works
Before React 17, all events were attached to document. Since React 17, events are attached to the root DOM container (#root):
// React 17+: listener attached to root container, not document
const root = document.getElementById('root');
// Internally, React does something like:
// root.addEventListener('click', dispatchEvent, true);
This change matters for micro-frontends and embedding React inside non-React pages — events no longer leak to document.
Why root container instead of document
When multiple React roots coexist on a page (micro-frontends), attaching to document caused e.stopPropagation() to fail — an event stopped in one React tree still fired in another because both listened on document. With React 17+, each tree listens on its own root, so stopPropagation works correctly between React roots. This also prevents React events from interfering with non-React event listeners on document.
SyntheticEvent: The Wrapper
React wraps native browser events in a SyntheticEvent that normalizes behavior across browsers:
function Input() {
function handleChange(event) {
// SyntheticEvent properties mirror native events:
event.target; // The DOM element
event.currentTarget; // The element with the handler
event.type; // 'change'
event.preventDefault();
event.stopPropagation();
// Access the real native event:
event.nativeEvent; // The original DOM event
}
return <input onChange={handleChange} />;
}
The onChange Difference
React's onChange fires on every keystroke, unlike the native change event which fires on blur:
// React onChange = fires on every character typed
<input onChange={(e) => setQuery(e.target.value)} />
// Native change event = fires when input loses focus
// element.addEventListener('change', handler);
This is one of the most significant deviations from native DOM behavior. React chose this because firing on every keystroke is what developers almost always want for controlled inputs.
Event Pooling: Removed in React 17
Before React 17, SyntheticEvent objects were pooled — after the event handler finished, all properties were nullified and the object was returned to a pool for reuse:
// React 16 (broken):
function handleClick(event) {
setTimeout(() => {
console.log(event.type); // null! Event was pooled.
}, 100);
}
// React 16 workaround:
function handleClick(event) {
event.persist(); // Remove from pool
setTimeout(() => {
console.log(event.type); // 'click' — works now
}, 100);
}
// React 17+: Pooling removed entirely.
// Events work as expected in async code.
If you see event.persist() in a codebase, it is legacy code from React 16. In React 17+, it is a no-op and can be safely removed.
stopPropagation Gotchas
stopPropagation in React only stops propagation within React's event system, not necessarily in the native DOM:
function App() {
// This native listener on document will fire BEFORE
// React's delegated handler, because React 17+ uses
// the root container, not document.
useEffect(() => {
document.addEventListener('click', () => {
console.log('document click'); // Fires first!
});
}, []);
function handleClick(event) {
event.stopPropagation(); // Stops React propagation
console.log('button click');
// But document listener already fired
}
return <button onClick={handleClick}>Click</button>;
}
event.stopPropagation() in a React handler stops the event from bubbling to parent React components, but it cannot stop native listeners attached to document that already captured the event. If you need to prevent document-level listeners from receiving the event, use event.nativeEvent.stopImmediatePropagation() — but this is fragile and order-dependent. The better fix is to restructure your event handling.
Production Scenario: Form Submission with Keyboard
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('');
function handleSubmit(event) {
event.preventDefault(); // Prevent page reload
if (query.trim()) {
onSearch(query.trim());
}
}
function handleKeyDown(event) {
// event.key is normalized by SyntheticEvent
if (event.key === 'Escape') {
setQuery('');
event.target.blur();
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
-
Wrong: Calling the handler instead of passing it: onClick=
{handleClick()}Right: Pass the function reference: onClick={handleClick} -
Wrong: Expecting React onChange to behave like native change (fires on blur) Right: React onChange fires on every keystroke for inputs
-
Wrong: Relying on stopPropagation to prevent document-level listeners Right: Native document listeners fire before React's delegated handler can stop them
-
Wrong: Using event.persist() in React 17+ Right: Remove event.persist() — pooling was removed in React 17
- 1React uses event delegation at the root container — not individual DOM listeners
- 2SyntheticEvent wraps native events and normalizes cross-browser behavior
- 3React onChange fires on every keystroke, unlike native change which fires on blur
- 4Event pooling was removed in React 17 — events are safe to use in async code
- 5Pass function references to handlers (onClick=
{fn}), not function calls (onClick={fn()})