React Synthetic Events

Difficulty: Intermediate

Question

What are React Synthetic Events? How does React's event system work?

Answer

Alright, let's talk about React's event system, because it's one of those things that works so seamlessly you might not even realize there's a whole abstraction layer sitting between your onClick handler and the browser. And understanding it will save you from some really confusing bugs.

So here's what happens. When you write onClick on a button in React, you're not actually attaching a native click listener to that button element. Instead, React creates something called a SyntheticEvent -- a wrapper object that sits on top of the browser's native event. Why? Because browsers are messy. Chrome, Firefox, Safari, and Edge all have slightly different event behaviors and properties. React's SyntheticEvent normalizes all of that so you get the exact same API no matter what browser your user is running. You call e.preventDefault(), e.stopPropagation(), access e.target -- it all works identically everywhere. Think of it like a universal adapter plug for events.

Now, here's where it gets interesting -- event delegation. Instead of attaching an individual event listener to every single button, input, and div in your app (which could be thousands of DOM nodes), React uses a pattern called event delegation. It attaches a single listener higher up in the DOM tree and then figures out which component the event belongs to when it bubbles up. This is way more efficient, especially for large apps with tons of interactive elements.

But there was a big change in React 17 that you should know about. Before React 17, React attached all its delegated listeners to the document object -- the very top of the DOM tree. This worked fine for single React apps, but it caused problems for micro-frontends, where you might have multiple React apps running on the same page. If one app called stopPropagation, it could accidentally prevent events from reaching another React app. So in React 17, the delegation target was moved from document down to the React root container -- the div you pass to createRoot. This means each React app on the page has its own event scope and they don't interfere with each other.

Another important change in React 17 was the removal of event pooling. This was a performance optimization in React 16 and earlier where React would reuse SyntheticEvent objects. After your event handler finished running, React would reset all the event's properties to null and reuse the object for the next event. Sounds efficient, right? But it caused a really common and confusing bug: if you tried to access the event asynchronously -- say, inside a setTimeout or after an await -- all the properties would be null. Developers had to call e.persist() to opt out of pooling. React 17 just removed pooling entirely because modern JavaScript engines are fast enough that creating a new event object each time is not a performance concern.

A few practical things to keep in mind. React uses camelCase for event names -- onClick, onChange, onMouseEnter, onKeyDown -- rather than the lowercase HTML attributes like onclick. The onChange event in React fires on every keystroke, which is actually different from the native HTML change event that only fires on blur. This catches a lot of people off guard.

If you ever need access to the raw browser event underneath, every SyntheticEvent has a nativeEvent property. You'd rarely need it, but it's there for edge cases like working with Canvas events or third-party libraries that expect native events.

One gotcha: the onScroll event in React does not bubble, which is intentionally different from native scroll events. Also, for touch and scroll performance, you sometimes need passive event listeners, which React doesn't support declaratively. For those, you'd use useEffect to add a native event listener with { passive: true } directly.

In production, understanding the synthetic event system helps you debug event-related issues faster. If events seem to fire in unexpected order, or stopPropagation isn't working the way you'd expect, it's usually because of the delegation model. And if you're building a micro-frontend architecture or embedding React inside a larger app, knowing that React 17+ scopes events to the root container is critical for avoiding cross-app event conflicts.

Code examples

Synthetic Events and nativeEvent

function Form() {
  function handleSubmit(e) {
    e.preventDefault(); // Works like native but cross-browser
    e.stopPropagation();

    // Access underlying native event
    console.log(e.nativeEvent instanceof Event); // true

    // React 17+: no event pooling - safe to access asynchronously
    setTimeout(() => {
      console.log(e.target.value); // Works in React 17+
      // In React 16: this would be null (pooled and reset!)
    }, 0);
  }

  function handleInput(e) {
    const value = e.target.value;
    // Safe to use value after the handler in React 17+
    doSomethingAsync(value);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleInput} />
    </form>
  );
}

// Event listener attachment (React 17+)
// <div id="root"> ← listeners attached here
//   <App />
// </div>
// vs React 16: document ← listeners attached here

In React 17+, synthetic events are no longer pooled - you can access event properties asynchronously. Listeners are on the root container, not document.

Stopping Propagation with Synthetic vs Native Events

function Portal() {
  const handleNativeClick = (e) => {
    // Native listener on document
    console.log('Native document listener fired');
  };

  useEffect(() => {
    document.addEventListener('click', handleNativeClick);
    return () => document.removeEventListener('click', handleNativeClick);
  }, []);

  return (
    <button
      onClick={(e) => {
        e.stopPropagation(); // Stops synthetic bubbling (React root)
        // But native listeners on document STILL fire in React 16!
        // In React 17+: stopping at root container works correctly
        console.log('Button clicked');
      }}
    >
      Click me
    </button>
  );
}

// React event names are camelCase
// <button onClick onChange onMouseEnter onKeyDown />

// Passive event listeners for performance
function TouchComponent() {
  useEffect(() => {
    const handler = (e) => { /* scroll handling */ };
    // passive: true prevents scroll jank
    window.addEventListener('touchstart', handler, { passive: true });
    return () => window.removeEventListener('touchstart', handler);
  }, []);
}

In React 17, stopPropagation works correctly because events are scoped to the root container. Passive event listeners must be added via useEffect for touch performance.

Key points

Concepts covered

Synthetic Events, Event Delegation, SyntheticEvent, nativeEvent, React 17