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.
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
documentobject, 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
divin that order, demonstrating how React processes events.
Event Binding
During reconciliation, JSX
onClickprops are stored in the element’s
props. In the
completeWorkphase, React creates a DOM node via
createInstanceand 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> legacyListenToEventretrieves 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:
Event Triggering
When a click occurs, React calls
dispatchEvent, which forwards to
dispatchEventForLegacyPluginEventSystem. It obtains a
bookKeepingobject 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> runExtractedPluginEventsInBatchcalls each plugin’s
extractEventsto create synthetic events. For the click example,
SimpleEventPlugin.extractEventsselects
SyntheticEventas 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,
executeDispatchesInOrderinvokes 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
setStatecalls 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_batchedUpdatesor 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.
onScrollno longer bubbles, and
onFocus/
onBlurmap to native
focusin/
focusoutevents.
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
windowand 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.
WeDoctor Frontend Technology
Official WeDoctor Group frontend public account, sharing original tech articles, events, job postings, and occasional daily updates from our tech team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.