React's event delegation, synthetic event wrapping, and bubbling from root.
React does not attach event listeners directly to DOM elements. Instead, it uses event delegation β a single listener is attached at the root container (the div where ReactDOM.createRoot() was called). When any native event bubbles up to the root, React intercepts it, wraps it in a SyntheticEvent object, and dispatches it through the React component tree.
When you write onClick='{'handleClick'}' in JSX, React does not call addEventListener on that DOM node. Instead, it registers a single listener on the root container for 'click' events β doing this once for the entire app, not once per component.
When you click a button, the browser fires a native click event on the button, which bubbles up through the real DOM tree to the document root β where React's listener is waiting.
React wraps the native event in a SyntheticEvent β a cross-browser normalized event object that exposes the same API regardless of browser. In older React versions, SyntheticEvents were pooled and reused for performance.
React traverses the fiber tree from the target element up to the root, calling any onClick (or other event) handlers it finds on each fiber. This simulates browser bubbling but in React's virtual tree.
Just like native events, React supports capture phase handlers via onClickCapture. These fire top-down (root β target) before bubble phase handlers fire bottom-up (target β root).
One listener at the root handles all events of that type. Much more efficient than attaching listeners to every interactive element.
React's cross-browser event wrapper. Normalizes inconsistencies between browsers. Exposes nativeEvent property for raw access.
Event propagates from target element upward through ancestor elements. Default phase for most React event handlers.
Event propagates from root downward to target before bubbling. Use onClickCapture for capture phase.
Stops the event from propagating further through React's simulated tree. Does NOT stop the native event unless called on e.nativeEvent.
1// React attaches ONE listener to the root β not to each button:2// root.addEventListener('click', reactHandler)34function App() {5 return (6 <div onClick={() => console.log('div β bubble')}>7 <button8 onClick={(e) => {9 console.log('button β bubble');10 // e.stopPropagation() would stop div from firing11 }}12 onClickCapture={() => console.log('button β capture')}13 >14 Click me15 </button>16 </div>17 );18}1920// Click order:21// 1. button β capture (capture phase, top-down)22// 2. button β bubble (bubble phase, bottom-up)23// 3. div β bubble (bubble phase, continues up)
Event delegation makes React highly efficient β no matter how many buttons are in your app, there's only one native event listener per event type. It also means React can intercept and handle events even for components added dynamically, and it enables React's synthetic event system to work consistently across all browsers.
You add a native `document.addEventListener('click', closeMenu)` to close a dropdown on outside click. Inside the dropdown, you call `e.stopPropagation()` on the React onClick β but the menu still closes.
React's stopPropagation stops propagation within React's synthetic event system (which uses delegation at the root). But the native listener on `document` has already received the event since React 17+ attaches to `root`, not `document`. The native handler fires before React can stop it.
Use `e.nativeEvent.stopImmediatePropagation()` to stop the native event from reaching other native listeners, or better yet, replace the native listener with a React `onClickCapture` on a wrapper div. Keep event handling within React's system to avoid mixed delegation conflicts.
Takeaway: React's event system runs inside a single delegated listener at the root container. Mixing React events with native `addEventListener` creates ordering conflicts. Prefer keeping all event logic within React's system for predictable behavior.
Legacy code from React 16 calls `e.persist()` in event handlers to use the event in async callbacks. After upgrading to React 18, you see that removing `e.persist()` works fine β but no one on the team understands why.
React 16 used event pooling β SyntheticEvent objects were reused after the handler returned, setting all properties to null. Accessing `e.target` in a setTimeout would return null unless you called `e.persist()`. Teams kept adding persist() defensively everywhere.
React 17+ removed event pooling entirely. SyntheticEvent objects are no longer reused β they persist naturally. All `e.persist()` calls can be safely removed. Understanding this change helps teams clean up legacy code and stop defensive coding patterns that are no longer needed.
Takeaway: React's event system evolves across versions. Event pooling removal (React 17), root-level delegation (React 17), and automatic batching in event handlers (React 18) are all changes that affect how you write event handling code. Stay current with the release notes.