Synthetic events in React
SyntheticEvent is React's cross-browser wrapper around native DOM events. It normalizes properties like event.target.value so they behave the same in Chrome, Firefox, and older IE, and uses a single root-level listener instead of one per element.
Theory
TL;DR
- Native events in different browsers have slightly different properties. SyntheticEvent gives you one consistent API on top.
- React attaches ONE listener to
#root, not one per element. That is event delegation. - In React 16, event objects were pooled and reused after the handler finished. Async access to
event.targetreturnednull. React 17 removed this behavior. e.targetis the element that triggered the event.e.currentTargetis the element where the handler lives.- Access the raw DOM event via
e.nativeEventwhen a third-party library needs it.
Quick example
function Button() {
const handleClick = (e) => {
console.log(e.type); // "click" - normalized
console.log(e.nativeEvent); // MouseEvent - the raw DOM event
e.preventDefault(); // works the same in every browser
};
return <a href="/somewhere" onClick={handleClick}>Click</a>;
}
// Prevents navigation, logs "click" with no browser differencesThe e React passes to your handler is not a native MouseEvent. It wraps one. Same shape, same methods, plus e.nativeEvent to reach the original.
How event delegation works
React does not attach a listener to each button, input, or link you render. It registers one listener on the root DOM node (#root in React 17+, document in React 16). Native events bubble up as usual. React intercepts at the root, wraps the native event in a SyntheticEvent object, normalizes properties, and dispatches to the matching handler.
A list of 1000 buttons still has exactly one event listener in the browser. Dynamic lists handle clicks without re-binding. The change from document to #root in React 17 matters when mixing React versions on one page. Each React tree now has its own isolated listener.
Event pooling: React 16 vs React 17
In React 16, SyntheticEvent objects were pooled. After your handler returned, React cleared all properties on the object and put it back in the pool for reuse. This broke async code in a non-obvious way:
// React 16 - breaks
function handleClick(e) {
setTimeout(() => {
console.log(e.target); // null - object was recycled
}, 0);
}The fix was e.persist(), which pulled the event out of the pool:
// React 16 - fixed
function handleClick(e) {
e.persist();
setTimeout(() => {
console.log(e.target); // <button>
}, 0);
}React 17 removed pooling entirely. Events are regular objects now. persist() still exists as a no-op for backward compatibility. I have seen the async-null bug in real production logs enough times that I now always capture event values synchronously, regardless of React version. The safer pattern works in both:
function handleClick(e) {
const value = e.target.value; // capture synchronously
setTimeout(() => console.log(value), 0); // always works
}event.target vs event.currentTarget
Say you have a button with a span inside:
<button onClick={handle}>
<span>Click me</span>
</button>The user clicks the <span>. Now e.target is <span> and e.currentTarget is <button>. The handler is on the button. The click landed on the span. For forms, read from e.currentTarget when you want the element that has the handler, not the element that received the click.
Common mistakes
Reading event properties in async code without capturing them first.
// Broke in React 16, works in React 17+ but still unclear intent
const handleChange = (e) => {
setTimeout(() => setState(e.target.value), 0);
};
// Explicit and safe everywhere
const handleChange = (e) => {
const val = e.target.value;
setTimeout(() => setState(val), 0);
};Using addEventListener inside useEffect for React-rendered elements.
// Avoid - bypasses React's delegation, can fire twice, leaks memory
useEffect(() => {
document.getElementById('btn').addEventListener('click', handler);
});
// Do this instead
<button onClick={handler}>Click</button>Native addEventListener in useEffect is fine for window-level events like resize or scroll that have no React prop equivalent. Always return a cleanup function.
Returning false to prevent the default action, like in plain HTML. In React, return false from an event handler does nothing. Call e.preventDefault() explicitly.
Confusing e.target and e.currentTarget in form handlers. When a form field contains nested elements, e.target may point to a child span. Always use e.currentTarget when you want the element with the handler.
Real-world usage
- React components - every
onClick,onChange,onSubmityou write receives a SyntheticEvent by default - Next.js - form handlers in the app router work the same way transparently
- Material UI - SyntheticEvents pass through from its button and input components to your handlers unchanged
- D3.js integration - use
e.nativeEventwhen D3 expects a raw DOM event object - TypeScript - import
MouseEvent,ChangeEvent,FormEventfromreact, not from DOM type definitions
Follow-up questions
Q: What is the difference between event.target and event.currentTarget?
A: target is the element that received the event, meaning the deepest element clicked. currentTarget is the element where the handler is attached. They differ when the clicked element is a child of the handler element.
Q: Why did React 17 move event listeners from document to #root?
A: Attaching to document caused conflicts when running two React versions on one page or mixing React with other frameworks. Attaching to the root node isolates each React tree so they do not interfere with each other.
Q: Is event.persist() still needed in React 17+?
A: No. Pooling was removed in React 17, so persist() is a no-op. You will see it in older codebases - safe to remove, harmless to leave.
Q: How does React simulate stopPropagation() inside its own dispatch system?
A: Calling e.stopPropagation() sets an internal flag in React's dispatch loop. React stops calling parent handlers. The native event still bubbles up to the root where React's single listener sits, but React does not dispatch it further up the component tree.
Q (senior): How do SyntheticEvents behave with portals?
A: Events from portal elements bubble through the React component tree, not the DOM tree. A click inside a modal portal will trigger handlers on the React parent even if the modal's DOM node is mounted outside that subtree. Use e.nativeEvent if you need the actual DOM propagation path.
Examples
Basic: preventing default navigation
function NavLink() {
const handleClick = (e) => {
e.preventDefault(); // stops browser navigation
console.log(e.type); // "click"
console.log(e.currentTarget.href); // the link's href attribute
};
return <a href="/dashboard" onClick={handleClick}>Dashboard</a>;
}
// Logs "click" and the href - no page reload happensNote that e.currentTarget here is the <a> tag, regardless of what child element was clicked inside it.
Intermediate: async form submission
function PaymentForm({ onSubmit }) {
const [card, setCard] = useState('');
const handleChange = (e) => {
setCard(e.target.value); // read synchronously - safe in all React versions
};
const handleSubmit = async (e) => {
e.preventDefault();
// card comes from state, not from the event object
// so there is no async pooling issue
const res = await fetch('/charge', {
method: 'POST',
body: JSON.stringify({ card }),
});
onSubmit(await res.json());
};
return (
<form onSubmit={handleSubmit}>
<input value={card} onChange={handleChange} placeholder="Card number" />
<button type="submit">Pay</button>
</form>
);
}
// handleChange reads event.target.value synchronously and stores in state.
// The async handler never touches the event object directly.Advanced: event capture phase
function CaptureDemo() {
return (
<div
onClickCapture={() => console.log('1. div capture')}
onClick={() => console.log('4. div bubble')}
>
<button
onClickCapture={() => console.log('2. button capture')}
onClick={() => console.log('3. button bubble')}
>
Click
</button>
</div>
);
}
// Output when button is clicked:
// 1. div capture
// 2. button capture
// 3. button bubble
// 4. div bubble
//
// React supports capture phase via the *Capture suffix on any event prop.
// Useful for intercepting events before they reach any child handler.Capture handlers run top-down before the element receives the event. This lets a parent component intercept and cancel events before any child has a chance to handle them.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.