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 document object, minimising memory overhead.
Simple Example
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>
);
}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.
function ensureListeningTo(rootContainerElement, registrationName) {
var doc = isDocumentOrFragment ? rootContainerElement : rootContainerElement.ownerDocument;
legacyListenToEvent(registrationName, doc);
} legacyListenToEventretrieves a listener map for the document and registers each native event dependency only once:
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);
}
}registrationNameDependencies data structure:
Event Triggering
When a click occurs, React calls dispatchEvent, which forwards to dispatchEventForLegacyPluginEventSystem. It obtains a bookKeeping object from a pool and invokes handleTopLevel:
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);
}
} runExtractedPluginEventsInBatchcalls 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.
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;
}
};The two‑phase accumulation walks up the fiber tree, building a list of listeners for the capture and bubble phases:
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);
}
}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:
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>
);
}
}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:
handleClickButton = (e) => {
e.nativeEvent.stopImmediatePropagation();
// ...
};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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
