Skip to main content

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.target returned null. React 17 removed this behavior.
  • e.target is the element that triggered the event. e.currentTarget is the element where the handler lives.
  • Access the raw DOM event via e.nativeEvent when a third-party library needs it.

Quick example

javascript
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 differences

The 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:

javascript
// 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:

javascript
// 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:

javascript
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:

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

javascript
// 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.

javascript
// 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, onSubmit you 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.nativeEvent when D3 expects a raw DOM event object
  • TypeScript - import MouseEvent, ChangeEvent, FormEvent from react, 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

javascript
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 happens

Note that e.currentTarget here is the <a> tag, regardless of what child element was clicked inside it.

Intermediate: async form submission

javascript
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

javascript
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?