Frontend Development 19 min read

Why Your React Click Handler Fails: Inside the Synthetic Event System

This article dissects React's synthetic event mechanism—from event delegation and pooling to dispatching and batching—using a button‑modal example, code walkthroughs, and comparisons between native and React event handling, while also covering improvements introduced in React 17.

WeDoctor Frontend Technology
WeDoctor Frontend Technology
WeDoctor Frontend Technology
Why Your React Click Handler Fails: Inside the Synthetic Event System

Weng Binbin, a front‑end engineer at WeDoctor Cloud, presents an analysis based on React 16.13.1.

Problem Statement

We try to create a button that shows a modal and closes it when clicking outside, but the button click does nothing. The issue stems from React's synthetic event system, which we will analyze.

Demo: https://codesandbox.io/s/event-uww15?file=/src/App.tsx:0-690

Synthetic Event Characteristics

React implements its own event system with three main features:

It normalises event capture and bubbling across browsers.

It uses an object pool to reuse synthetic event objects, reducing garbage collection.

All events are bound once on the

document

object, minimising memory overhead.

Simple Example

<code>function App() {
  function handleButtonLog(e: React.MouseEvent<HTMLButtonElement>) {
    console.log(e.currentTarget);
  }
  function handleDivLog(e: React.MouseEvent<HTMLDivElement>) {
    console.log(e.currentTarget);
  }
  function handleH1Log(e: React.MouseEvent<HTMLElement>) {
    console.log(e.currentTarget);
  }
  return (
    <div onClick={handleDivLog}>
      <h1 onClick={handleH1Log}>
        <button onClick={handleButtonLog}>click</button>
      </h1>
    </div>
  );
}
</code>

Running this code logs

button

,

h1

, and

div

in that order, demonstrating how React processes events.

Event Binding

During reconciliation, JSX

onClick

props are stored in the element’s

props

. In the

completeWork

phase, React creates a DOM node via

createInstance

and assigns props with

finalizeInitialChildren

. When it encounters event props like

onClick

, it triggers the binding logic.

<code>function ensureListeningTo(rootContainerElement, registrationName) {
  var doc = isDocumentOrFragment ? rootContainerElement : rootContainerElement.ownerDocument;
  legacyListenToEvent(registrationName, doc);
}
</code>
legacyListenToEvent

retrieves a listener map for the document and registers each native event dependency only once:

<code>function legacyListenToEvent(registrationName, mountAt) {
  var listenerMap = getListenerMapForElement(mountAt);
  var dependencies = registrationNameDependencies[registrationName];
  for (var i = 0; i < dependencies.length; i++) {
    var dependency = dependencies[i];
    legacyListenToTopLevelEvent(dependency, mountAt, listenerMap);
  }
}
</code>
registrationNameDependencies data structure:
registrationNameDependencies diagram
registrationNameDependencies diagram

Event Triggering

When a click occurs, React calls

dispatchEvent

, which forwards to

dispatchEventForLegacyPluginEventSystem

. It obtains a

bookKeeping

object from a pool and invokes

handleTopLevel

:

<code>function handleTopLevel(bookKeeping) {
  var targetInst = bookKeeping.targetInst;
  var ancestor = targetInst;
  do {
    var tag = ancestor.tag;
    if (tag === HostComponent || tag === HostText) {
      bookKeeping.ancestors.push(ancestor);
    }
  } while (ancestor);
  for (var i = 0; i < bookKeeping.ancestors.length; i++) {
    var targetInst = bookKeeping.ancestors[i];
    runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, eventTarget, eventSystemFlags);
  }
}
</code>
runExtractedPluginEventsInBatch

calls each plugin’s

extractEvents

to create synthetic events. For the click example,

SimpleEventPlugin.extractEvents

selects

SyntheticEvent

as the constructor, pools an event instance, and accumulates two‑phase dispatches.

<code>var SimpleEventPlugin = {
  extractEvents: function(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
    var EventConstructor;
    switch (topLevelType) {
      case TOP_KEY_DOWN:
      case TOP_KEY_UP:
        EventConstructor = SyntheticKeyboardEvent; break;
      case TOP_BLUR:
      case TOP_FOCUS:
        EventConstructor = SyntheticFocusEvent; break;
      default:
        EventConstructor = SyntheticEvent; break;
    }
    var event = EventConstructor.getPooled(dispatchConfig, targetInst, nativeEvent, nativeEventTarget);
    accumulateTwoPhaseDispatches(event);
    return event;
  }
};
</code>

The two‑phase accumulation walks up the fiber tree, building a list of listeners for the capture and bubble phases:

<code>function traverseTwoPhase(inst, fn, arg) {
  var path = [];
  while (inst) {
    path.push(inst);
    inst = getParent(inst);
  }
  for (var i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }
  for (var i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
}
</code>

During execution,

executeDispatchesInOrder

invokes each listener, respects

event.isPropagationStopped()

, and releases the event back to the pool unless

event.persist()

was called.

Event Unbinding

Because React binds each event type only once on the document, it does not unbind events when components unmount.

Batch Updates

React batches multiple

setState

calls triggered by its own synthetic events, while native listeners cause separate renders. The following component demonstrates the difference:

<code>export default class EventBatchUpdate extends React.PureComponent {
  state = { count: 0 };
  button = React.createRef();
  componentDidMount() {
    this.button.current.addEventListener('click', this.handleNativeClickButton, false);
  }
  handleNativeClickButton = () => {
    this.setState(prev => ({ count: prev.count + 1 }));
    this.setState(prev => ({ count: prev.count + 1 }));
  };
  handleClickButton = () => {
    this.setState(prev => ({ count: prev.count + 1 }));
    this.setState(prev => ({ count: prev.count + 1 }));
  };
  render() {
    console.log('update');
    return (
      <div>
        <h1>legacy event</h1>
        <button ref={this.button}>native event add</button>
        <button onClick={this.handleClickButton}>React event add</button>
        {this.state.count}
      </div>
    );
  }
}
</code>

Clicking the native button logs two updates; clicking the React button logs one. Using

ReactDOM.unstable_batchedUpdates

or enabling Concurrent Mode restores batching for native listeners.

React 17 Event Improvements

Event delegation now attaches to the root container instead of

document

, preventing cross‑version interference.

Event pooling has been removed; synthetic events are no longer reused.

onScroll

no longer bubbles, and

onFocus

/

onBlur

map to native

focusin

/

focusout

events.

Capture listeners (e.g.,

onClickCapture

) now use the browser’s native capture phase.

Solutions for the Original Issue

In React 16, you can stop the native event from propagating further:

<code>handleClickButton = (e) => {
  e.nativeEvent.stopImmediatePropagation();
  // ...
};
</code>

Or bind the outer listener to

window

and call

e.nativeEvent.stopPropagation()

inside the inner handler.

In React 17, no code changes are required because events are no longer bound to

document

.

Conclusion

By dissecting a classic example, we traced React’s event system from delegation and pooling to dispatch and batching, highlighted design decisions, and provided practical fixes for both React 16 and React 17 environments.

summary illustration
summary illustration
ReactSyntheticEventReact17BatchUpdateEventDelegation
WeDoctor Frontend Technology
Written by

WeDoctor Frontend Technology

Official WeDoctor Group frontend public account, sharing original tech articles, events, job postings, and occasional daily updates from our tech team.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.