You’ve probably hit this: you attach multiple event handlers to your React app , some on native DOM elements, some on React components, maybe even some portals thrown in , and suddenly your callbacks run in an order that feels all wrong.
For example, a click handler on a parent fires after a child’s handler, or a stopPropagation call seems ignored. The UI acts buggy and unpredictable. You scratch your head, double-check handler registration order, and wonder, “Why is React firing these handlers this way?”
I ran into this mess recently in a complex React app with nested modals and portal layers. The problem? React’s synthetic event system interacting subtly but crucially with native DOM event propagation. Understanding this interaction was key to diagnosing and fixing the bug.
React’s Synthetic Events Aren’t the Same as DOM Events
React implements its own event system called SyntheticEvent to normalize cross-browser quirks , that’s great. But this system also means React doesn’t attach event listeners directly to every DOM node. Instead, React sets a single event listener at the root (usually document) for each event type.
When a native event bubbles up to the document, React captures it and dispatches synthetic events to your components based on the React tree.
This is sometimes called "event delegation."
Why does this matter? Because React’s synthetic events are dispatched after the native DOM event has bubbled up to the document level, not during the native DOM event phases (capturing or bubbling).
That means React’s synthetic event handlers run after all native event handlers attached to the DOM elements have fired.
This timing difference is the root of many event order surprises.
Native DOM Events vs React Synthetic Events: The Ordering Gotcha
Let’s say you have this React tree:
<div onClick={() => console.log('React Div click')}>
<button id="native-btn">Click me</button>
</div>
And somewhere else, you add a native DOM event handler on the button via addEventListener:
document.getElementById('native-btn').addEventListener('click', () => {
console.log('Native button click');
});
When you click the button, here’s the order you’ll see in the console:
Native button click
React Div click
That’s because the native handler runs during the DOM event bubbling phase before React’s synthetic event system dispatches events at the document level.
If you expected React’s onClick handler to run first because it’s on a parent, you’re in for surprises.
StopPropagation and Event Phases: Why Calling It in React Doesn’t Always Work
Another common trap: you call event.stopPropagation() inside a React handler to block parent handlers, but you still see parent handlers firing.
Why? Because React’s synthetic events are dispatched after native event propagation has finished. By the time React’s synthetic event system runs your handler’s stopPropagation, the native DOM event has already bubbled through the ancestors.
In other words, React’s synthetic event system can’t stop native DOM events from propagating, since it listens at the document level after bubbling completes.
If you also have native event handlers on the same elements or parents, they will run regardless of your React handler’s stopPropagation.
What About Portals? They Add Another Twist
React portals render DOM elements outside the React root. But React event delegation still works , React tracks the portal’s React tree and dispatches synthetic events accordingly.
This means a portal’s event handlers fire in React’s synthetic event order, but the portal DOM nodes are siblings or children of other DOM elements outside React’s root.
If native event handlers are attached outside React’s root or on portal DOM nodes directly, you can get extra surprises where native handlers run before or after React handlers in ways that don’t match the React tree hierarchy.
Diagnosing Event Handler Ordering Bugs
Here’s how I approach these bugs:
1. Identify which handlers are native DOM handlers vs React synthetic handlers
Look at how each handler is attached: React’s JSX event props vs addEventListener. Remember React handlers run after native handlers.
2. Check event phases and stopPropagation usage
If you want to block parent handlers, consider whether the handlers are React synthetic, native, or both. Calling stopPropagation in React events won’t stop native events that already bubbled.
3. Use browser DevTools to inspect event listeners
In Chrome, the Elements panel lets you see event listeners attached to nodes. Native listeners show directly; React synthetic listeners won’t appear directly on nodes since they’re delegated.
4. Add console logs or breakpoints in both native and React handlers
See the exact order they run. This clarifies how event flow moves through native then React.
5. Consider using native event handlers inside React via useEffect and addEventListener
If you need precise control over event order or phases, attach native handlers on refs and manage event phases explicitly.
How to Fix Common Ordering Bugs
- If you want parent React handlers to run before child handlers:
React doesn’t support event capturing phase in its synthetic system by default (only bubbling). You can use onClickCapture props for capturing phase handlers.
For example:
<div onClickCapture={() => console.log('Parent capture')}>
<button onClick={() => console.log('Child bubble')}>Click</button>
</div>
- To stop native event propagation properly:
Attach native handlers and call event.stopPropagation() there, or manage event order by controlling native event listeners carefully.
Avoid mixing native and React handlers for the same events and elements unless you’re sure about order and side effects.
In portals, be mindful that React synthetic events still delegate to the React tree, but native events on portal DOM nodes might not follow your React component hierarchy intuitively.
A Real Debugging Story
In my app, a modal rendered via a portal had a close button. The button had a native addEventListener click handler, while the modal’s backdrop had a React onClick handler.
Clicking the button closed the modal, but also triggered the backdrop’s onClick, unexpectedly closing it twice and causing weird state bugs.
The root cause: the native button click handler ran first, then React’s synthetic backdrop click handler ran afterward, because React’s synthetic events fire after native events.
Fixing this involved removing the native listener and moving all handlers to React props, using event.stopPropagation() in the button’s React handler to prevent backdrop clicks.
Bottom Line
Event handler order bugs in React apps often boil down to this:
- React’s synthetic events run at the document level after native bubbling.
- Native event handlers run during native capture/bubble phases.
- Calling stopPropagation in React synthetic events can’t prevent native handlers that already ran.
- Portals add complexity because React event delegation stays within the React tree, but native events can be wired outside.
When you get weird event ordering or propagation bugs, step back and ask: which handlers are native? Which are React synthetic? When and where do they run?
Debugging with this mental model helped me avoid many subtle bugs and write event handlers that behave as expected. I hope it helps you too.
Helpful learning resources
- MDN Web Docs
- React documentation
- OpenAI developer resources Originally published at Under The Hood. Get the next deep dive in your inbox: subscribe to Under The Hood.
Top comments (0)