Skip to content

Event Handling and Synthetic Events

intermediate11 min read

React Events Are Not DOM Events

When you write onClick={handler} in React, you are not adding an onclick attribute or calling addEventListener on that DOM element. React uses event delegation — it attaches a single listener to the root of your application and dispatches events to the correct component handler.

function Button() {
  function handleClick(event) {
    // event is a SyntheticEvent, not a native MouseEvent
    console.log(event.constructor.name); // SyntheticBaseEvent
    console.log(event.nativeEvent.constructor.name); // MouseEvent
  }

  return <button onClick={handleClick}>Click me</button>;
}
Mental Model

Think of React's event system as a receptionist at a large office building. Instead of every office (DOM node) having its own doorbell, there is one receptionist at the front desk (root container). When a visitor (event) arrives, the receptionist checks the directory (fiber tree), finds the right office (component), and routes the visitor there. The receptionist also translates the visitor's message into a standardized format (SyntheticEvent) so every office handles visitors the same way.

Event Delegation: How It Works

Before React 17, all events were attached to document. Since React 17, events are attached to the root DOM container (#root):

// React 17+: listener attached to root container, not document
const root = document.getElementById('root');
// Internally, React does something like:
// root.addEventListener('click', dispatchEvent, true);

This change matters for micro-frontends and embedding React inside non-React pages — events no longer leak to document.

Why root container instead of document

When multiple React roots coexist on a page (micro-frontends), attaching to document caused e.stopPropagation() to fail — an event stopped in one React tree still fired in another because both listened on document. With React 17+, each tree listens on its own root, so stopPropagation works correctly between React roots. This also prevents React events from interfering with non-React event listeners on document.

SyntheticEvent: The Wrapper

React wraps native browser events in a SyntheticEvent that normalizes behavior across browsers:

function Input() {
  function handleChange(event) {
    // SyntheticEvent properties mirror native events:
    event.target;          // The DOM element
    event.currentTarget;   // The element with the handler
    event.type;            // 'change'
    event.preventDefault();
    event.stopPropagation();

    // Access the real native event:
    event.nativeEvent;     // The original DOM event
  }

  return <input onChange={handleChange} />;
}

The onChange Difference

React's onChange fires on every keystroke, unlike the native change event which fires on blur:

// React onChange = fires on every character typed
<input onChange={(e) => setQuery(e.target.value)} />

// Native change event = fires when input loses focus
// element.addEventListener('change', handler);

This is one of the most significant deviations from native DOM behavior. React chose this because firing on every keystroke is what developers almost always want for controlled inputs.

Event Pooling: Removed in React 17

Before React 17, SyntheticEvent objects were pooled — after the event handler finished, all properties were nullified and the object was returned to a pool for reuse:

// React 16 (broken):
function handleClick(event) {
  setTimeout(() => {
    console.log(event.type); // null! Event was pooled.
  }, 100);
}

// React 16 workaround:
function handleClick(event) {
  event.persist(); // Remove from pool
  setTimeout(() => {
    console.log(event.type); // 'click' — works now
  }, 100);
}

// React 17+: Pooling removed entirely.
// Events work as expected in async code.
Info

If you see event.persist() in a codebase, it is legacy code from React 16. In React 17+, it is a no-op and can be safely removed.

stopPropagation Gotchas

stopPropagation in React only stops propagation within React's event system, not necessarily in the native DOM:

function App() {
  // This native listener on document will fire BEFORE
  // React's delegated handler, because React 17+ uses
  // the root container, not document.
  useEffect(() => {
    document.addEventListener('click', () => {
      console.log('document click'); // Fires first!
    });
  }, []);

  function handleClick(event) {
    event.stopPropagation(); // Stops React propagation
    console.log('button click');
    // But document listener already fired
  }

  return <button onClick={handleClick}>Click</button>;
}
Common Trap

event.stopPropagation() in a React handler stops the event from bubbling to parent React components, but it cannot stop native listeners attached to document that already captured the event. If you need to prevent document-level listeners from receiving the event, use event.nativeEvent.stopImmediatePropagation() — but this is fragile and order-dependent. The better fix is to restructure your event handling.

Production Scenario: Form Submission with Keyboard

function SearchForm({ onSearch }) {
  const [query, setQuery] = useState('');

  function handleSubmit(event) {
    event.preventDefault(); // Prevent page reload
    if (query.trim()) {
      onSearch(query.trim());
    }
  }

  function handleKeyDown(event) {
    // event.key is normalized by SyntheticEvent
    if (event.key === 'Escape') {
      setQuery('');
      event.target.blur();
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Search..."
      />
      <button type="submit">Search</button>
    </form>
  );
}
Execution Trace
Click:
User clicks `button`
Native click event fires in DOM
Bubble:
Event bubbles up to root container
React's delegated listener captures it
Fiber lookup:
React walks fiber tree to find handler
Matches the button's fiber node
Synthetic:
SyntheticEvent wraps native MouseEvent
Cross-browser normalization applied
Capture:
Capture phase handlers run (onClickCapture)
Root to target, top-down
Bubble:
Bubble phase handlers run (onClick)
Target to root, bottom-up
Common Mistakes
  • Wrong: Calling the handler instead of passing it: onClick={handleClick()} Right: Pass the function reference: onClick={handleClick}

  • Wrong: Expecting React onChange to behave like native change (fires on blur) Right: React onChange fires on every keystroke for inputs

  • Wrong: Relying on stopPropagation to prevent document-level listeners Right: Native document listeners fire before React's delegated handler can stop them

  • Wrong: Using event.persist() in React 17+ Right: Remove event.persist() — pooling was removed in React 17

Quiz
Where does React 17+ attach its event listeners?
Quiz
What does onClick={handleClick()} do?
Quiz
In React 17+, what happens if you access event.target inside a setTimeout?
Key Rules
  1. 1React uses event delegation at the root container — not individual DOM listeners
  2. 2SyntheticEvent wraps native events and normalizes cross-browser behavior
  3. 3React onChange fires on every keystroke, unlike native change which fires on blur
  4. 4Event pooling was removed in React 17 — events are safe to use in async code
  5. 5Pass function references to handlers (onClick={fn}), not function calls (onClick={fn()})

Challenge: Fix the Event Bug

Event Propagation Debugging