The Event System
React doesn't attach your onClick to the button. When you call createRoot(container), it puts one capture and one bubble listener per native event type on the root container. On a…
- Published
- Reading time
- 9 min read
This page sits between the user and setState. It explains why a click produces a SyncLane update, and how React finds your handler without any per-element listeners.
1. Setup: listenToAllSupportedEvents
createRoot (and hydrateRoot) call listenToAllSupportedEvents(rootContainerElement) once per container.1
export function listenToAllSupportedEvents(rootContainerElement) {
if (!rootContainerElement[listeningMarker]) {
rootContainerElement[listeningMarker] = true;
allNativeEvents.forEach(domEventName => {
if (domEventName !== 'selectionchange') {
if (!nonDelegatedEvents.has(domEventName)) {
listenToNativeEvent(domEventName, false, rootContainerElement); // bubble
}
listenToNativeEvent(domEventName, true, rootContainerElement); // capture
}
});
// 'selectionchange' only fires on document → listen there once
}
}(simplified)2
allNativeEventsis filled at module load.SimpleEventPlugin.registerEvents()and the polyfill plugins (EnterLeave, Change, Select, BeforeInput) register their event names.3registerSimpleEventsbuilds thedomEventName → reactNamemap,click → onClick, plus special cases. Notefocusin → onFocusandfocusout → onBlur: React'sonFocusbubbles because it's reallyfocusin.4nonDelegatedEvents(scroll,scrollend,load,cancel,close,invalid,toggle,beforetoggleand all media events) don't bubble in the DOM. They get only a capture listener on the root here. React also attaches them directly to the element when it creates a host instance that needs them (listenToNonDelegatedEvent).5addTrappedEventListenercreates the actual listener viacreateEventListenerWrapperWithPriority. It markstouchstart,touchmoveandwheelas{passive: true}where supported, so React never blocks scrolling.6
Why delegate at all? A table with 10,000 rows would otherwise need 10,000 listeners, re-attached on every mount. With delegation, attaching an onClick costs nothing beyond storing the prop, and handlers can change every render without any add/removeEventListener churn. The cost is an extra walk up the fiber tree on each event.
2. Priority is baked into the listener
export function createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags) {
const eventPriority = getEventPriority(domEventName);
let listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority: listenerWrapper = dispatchDiscreteEvent; break;
case ContinuousEventPriority: listenerWrapper = dispatchContinuousEvent; break;
default: listenerWrapper = dispatchEvent; break;
}
return listenerWrapper.bind(null, domEventName, eventSystemFlags, targetContainer);
}getEventPriority is a big switch. click, keydown, input, mousedown, pointerup, focusin and similar are Discrete. mousemove, pointermove, scroll, wheel, drag and mouseenter/leave are Continuous. message maps from the current Scheduler priority. Everything else is Default.9
function dispatchDiscreteEvent(domEventName, eventSystemFlags, container, nativeEvent) {
const prevTransition = ReactSharedInternals.T;
ReactSharedInternals.T = null; // a click is never inside a transition
const previousPriority = getCurrentUpdatePriority();
try {
setCurrentUpdatePriority(DiscreteEventPriority); // ReactDOMSharedInternals.p = SyncLane
dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
} finally {
setCurrentUpdatePriority(previousPriority);
ReactSharedInternals.T = prevTransition;
}
}This is the link to setState. requestUpdateLane → resolveUpdatePriority() reads ReactDOMSharedInternals.p, finds DiscreteEventPriority (= SyncLane), and every setState in your click handler gets SyncLane.11 dispatchContinuousEvent does the same thing with ContinuousEventPriority (= InputContinuousLane).12 The priority is a dynamic scope: it's set around the call and restored afterwards, the same pattern flushSync and startTransition use.
3. dispatchEvent: from DOM node to fiber
dispatchEvent first calls findInstanceBlockingEvent(nativeEvent). That gets the event target, then calls findInstanceBlockingTarget, which uses getClosestInstanceFromNode(target) to get the fiber.1314 During hydration the target may sit inside a dehydrated Suspense/Activity boundary. In that case the event is "blocked": continuous events are queued for replay, and discrete events try to hydrate synchronously first. In the normal case it's not blocked, and dispatchEventForPluginEventSystem is called with the target fiber.
How a DOM node knows its fiber: when completeWork creates a host instance, createInstance calls precacheFiberNode(fiber, domElement) and updateFiberProps(domElement, props).15 These write two expando properties with a per-load random suffix: '__reactFiber$' + randomKey and '__reactProps$' + randomKey.161718 commitUpdate refreshes __reactProps$ on every update, so the props there are always the latest committed props.19
getClosestInstanceFromNode reads node[internalInstanceKey]. If the target isn't React-owned (innerHTML content, a third-party widget), it walks parentNode until it finds one.20 (enableInternalInstanceMap, which uses a WeakMap instead of expandos, is false in this release.)
dispatchEventForPluginEventSystem then handles portals and nested roots. It walks up to find the HostRoot/HostPortal whose container matches the container that received the event, so an event isn't handled twice by nested roots. Finally it calls batchedUpdates(() => dispatchEventsForPlugins(...)).21 In 19.x OSS the DOM-level batchedUpdates mostly exists to run finishEventHandler afterwards. That restores controlled inputs, flushing sync work first if a controlled component needs it.22
4. extractEvents and accumulateSinglePhaseListeners
dispatchEventsForPlugins creates an empty dispatchQueue, calls extractEvents, and then processDispatchQueue.23 extractEvents always runs SimpleEventPlugin.extractEvents. For the bubble phase it also runs the polyfill plugins: EnterLeave (synthesises onMouseEnter from mouseover/mouseout), Change, Select, BeforeInput, and FormAction.24
SimpleEventPlugin.extractEvents maps click → onClick and picks the synthetic event constructor (SyntheticMouseEvent for click). It works out inCapturePhase from the listener flags, then collects listeners:25
const listeners = accumulateSinglePhaseListeners(
targetInst, reactName /* 'onClick' */, nativeEvent.type,
inCapturePhase, accumulateTargetOnly /* true for non-bubbling scroll */, nativeEvent,
);
if (listeners.length > 0) {
const event = new SyntheticEventCtor(reactName, reactEventType, null, nativeEvent, nativeEventTarget);
dispatchQueue.push({ event, listeners });
}export function accumulateSinglePhaseListeners(targetFiber, reactName, nativeEventType,
inCapturePhase, accumulateTargetOnly, nativeEvent) {
const reactEventName = inCapturePhase ? reactName + 'Capture' : reactName; // onClickCapture
const listeners = [];
let instance = targetFiber;
let lastHostComponent = null;
while (instance !== null) {
const { stateNode, tag } = instance;
if ((tag === HostComponent || tag === HostHoistable || tag === HostSingleton) && stateNode !== null) {
lastHostComponent = stateNode;
const listener = getListener(instance, reactEventName); // stateNode.__reactProps$[...].onClick
if (listener != null) listeners.push({ instance, listener, currentTarget: lastHostComponent });
}
if (accumulateTargetOnly) break;
instance = instance.return;
}
return listeners; // ordered target → root
}(simplified, createEventHandle/Scope API branches dropped)26
What matters:
- It walks the fiber tree, not the DOM tree. That's why events bubble through portals to their React parent, even when the portal's DOM lives in
document.body.instance.returnfollows the component hierarchy. - Only host fibers contribute.
<MyButton onClick>on a function component is just a prop. Only when it reaches a<button>(HostComponent) isonClickread, and it's read from the DOM node's current props (getListener→getFiberCurrentPropsFromNode), not fromfiber.memoizedProps.27 SincecommitUpdateupdates__reactProps$during commit, handlers are always the latest committed ones, with no listener re-binding. getListenerreturnsnullfor mouse events ondisabledinteractive elements (button,input,select,textarea). That matches browser behaviour across all of them.27- One SyntheticEvent per dispatch, shared by all listeners in the path. Its
currentTargetis set per listener.
5. SyntheticEvent and processDispatchQueue
createSyntheticEvent(Interface) returns a constructor that copies the interface's fields from the native event (normalising cross-browser quirks) and stores nativeEvent, target, type and _reactName. It implements preventDefault()/stopPropagation() by calling the native method and flipping isDefaultPrevented/isPropagationStopped.28 persist() is a no-op and isPersistent() always returns true. Event pooling was removed in React 17, and the methods are kept only for compatibility.28
function processDispatchQueueItemsInOrder(event, dispatchListeners, inCapturePhase) {
let previousInstance;
if (inCapturePhase) {
for (let i = dispatchListeners.length - 1; i >= 0; i--) { /* root → target */ }
} else {
for (let i = 0; i < dispatchListeners.length; i++) { /* target → root */
const { instance, currentTarget, listener } = dispatchListeners[i];
if (instance !== previousInstance && event.isPropagationStopped()) return;
executeDispatch(event, listener, currentTarget); // try { listener(event) } catch → reportGlobalError
previousInstance = instance;
}
}
}Capture and bubble are simulated in one list by iterating it backwards or forwards. The capture-phase native listener collects onClickCapture handlers and runs them root→target. The bubble-phase native listener collects onClick and runs them target→root. An error thrown in one handler is reported via reportGlobalError (and window.onerror). It doesn't stop the other handlers.
6. End to end: a click becomes pixels
sequenceDiagram
participant U as User
participant B as Browser
participant R as Root container listener
participant P as Plugin system
participant H as Your onClick
participant S as Root scheduler
U->>B: click <button>
B->>R: native 'click' (capture listener: onClickCapture handlers)
B->>R: native 'click' (bubble listener)
R->>R: dispatchDiscreteEvent: priority = Discrete (SyncLane), T = null
R->>P: dispatchEvent → getClosestInstanceFromNode(target) → fiber
P->>P: SimpleEventPlugin: accumulateSinglePhaseListeners (fiber.return walk)
P->>H: processDispatchQueue → listener(syntheticEvent)
H->>S: setCount() → requestUpdateLane = SyncLane → enqueue → ensureRootIsScheduled
S-->>S: queueMicrotask (once)
R->>R: restore priority, finishEventHandler
Note over B,S: listener returns, JS stack empty → microtask checkpoint
S->>S: processRootScheduleInMicrotask → flushSyncWorkAcrossRoots → performSyncWorkOnRoot
S->>B: render + commit (DOM mutated, layout effects run)
B->>U: style/layout/paint the new frameBecause the update is SyncLane, scheduleTaskForRootDuringMicrotask doesn't create a Scheduler task. The render runs straight from flushSyncWorkAcrossRoots_impl inside the microtask.3233 Microtasks run before the browser gets a chance to render a frame, so the DOM is updated before the next paint. Passive effects (useEffect) from a discrete update are also flushed synchronously at the end of the commit rather than later34, so the UI is consistent before any further input (see Commit Phase). The full trace with every function is in setState Walkthrough.
With the React Compiler
Unchanged. The event system lives in react-dom-bindings and runs no component code apart from your handlers. What the compiler changes is handler identity. Without it, onClick={() => setCount(c => c + 1)} is a new function every render, which is harmless for DOM elements: React just reads the latest one from __reactProps$, and attribute diffing treats event props specially. But it breaks memo on child components that receive the handler. The compiler caches such closures in its memo cache (keyed on the values they capture), so memo children and effect deps see stable functions. See React Compiler and With vs Without Compiler.
Summary
React never puts your onClick on the button. createRoot installs one capture and one bubble listener per native event type on the root container, each wrapped with a priority. On a click, React finds the target's fiber through a hidden key on the DOM node, walks fiber.return collecting handlers, and calls them with one shared SyntheticEvent. The priority scope around that call is the link to setState: every setState in the handler gets SyncLane and is rendered and committed in a microtask before paint. Next, the compiler, which changes handler identity but not this pipeline.
Key takeaways
listenToAllSupportedEvents: runs once per container;nonDelegatedEventsget only a capture listener here and are also attached to the element.createEventListenerWrapperWithPriority: picks the wrapper fromgetEventPriority, so priority is baked into the listener.dispatchDiscreteEvent: setsDiscreteEventPriority(=SyncLane) and clears the transition around the dispatch, then restores both.__reactFiber$/__reactProps$: expandos written bycreateInstanceand refreshed bycommitUpdate; they map a DOM target to its fiber and hold the latest committed props.accumulateSinglePhaseListeners: walks the fiber tree, not the DOM, so events bubble through portals; only host fibers contribute.processDispatchQueue: runs one list backwards for capture and forwards for bubble; a throwing handler doesn't stop the others.SyntheticEvent: one per dispatch, shared by all listeners;persist()is a no-op since pooling was removed.- Sync flush: a
SyncLaneupdate gets no Scheduler task and renders in a microtask, so the DOM changes before the next paint.
Footnotes
-
createRootcallslistenToAllSupportedEvents— packages/react-dom/src/client/ReactDOMRoot.js#L171-L262 (opens in a new tab) ↩ -
listenToAllSupportedEvents— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L432-L459 (opens in a new tab) ↩ -
plugin registration — packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L93-L100 (opens in a new tab) ↩
-
registerSimpleEvent/registerSimpleEvents— packages/react-dom-bindings/src/events/DOMEventProperties.js#L125-L149 (opens in a new tab) ↩ -
nonDelegatedEvents— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L236-L250 (opens in a new tab) ↩ -
addTrappedEventListener— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L461-L558 (opens in a new tab) ↩ -
"React v17.0 Release Candidate: No New Features", Changes to Event Delegation — https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html (opens in a new tab) ↩
-
createEventListenerWrapperWithPriority— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L94-L119 (opens in a new tab) ↩ -
getEventPriority— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L312-L416 (opens in a new tab) ↩ -
dispatchDiscreteEvent— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L121-L137 (opens in a new tab) ↩ -
resolveUpdatePriority— packages/react-dom-bindings/src/client/ReactDOMUpdatePriority.js#L35-L45 (opens in a new tab) ↩ -
dispatchContinuousEvent— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L139-L155 (opens in a new tab) ↩ -
dispatchEvent— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L157-L235 (opens in a new tab) ↩ -
findInstanceBlockingTarget— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L248-L310 (opens in a new tab) ↩ -
createInstancecallsprecacheFiberNode+updateFiberProps— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L534-L664 (opens in a new tab) ↩ -
internalInstanceKey/internalPropsKey— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L43-L45 (opens in a new tab) ↩ -
precacheFiberNode— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L94-L111 (opens in a new tab) ↩ -
getFiberCurrentPropsFromNode/updateFiberProps— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L281-L301 (opens in a new tab) ↩ -
commitUpdate→updateFiberProps— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L999-L1012 (opens in a new tab) ↩ -
getClosestInstanceFromNode— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L136-L223 (opens in a new tab) ↩ -
dispatchEventForPluginEventSystem— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L589-L703 (opens in a new tab) ↩ -
DOM
batchedUpdates/finishEventHandler— packages/react-dom-bindings/src/events/ReactDOMUpdateBatching.js#L27-L59 (opens in a new tab) ↩ -
dispatchEventsForPlugins— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L325-L344 (opens in a new tab) ↩ -
extractEvents(plugin fan-out) — packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L102-L203 (opens in a new tab) ↩ -
SimpleEventPlugin.extractEvents— packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js#L57-L235 (opens in a new tab) ↩ -
accumulateSinglePhaseListeners— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L717-L829 (opens in a new tab) ↩ -
getListener(reads current props from DOM node; disabled check) — packages/react-dom-bindings/src/events/getListener.js#L14-L78 (opens in a new tab) ↩ ↩2 -
createSyntheticEvent(persistno-op,isPersistent) — packages/react-dom-bindings/src/events/SyntheticEvent.js#L31-L143 (opens in a new tab) ↩ ↩2 -
processDispatchQueueItemsInOrder— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L266-L311 (opens in a new tab) ↩ -
executeDispatch— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L252-L264 (opens in a new tab) ↩ -
processDispatchQueue— packages/react-dom-bindings/src/events/DOMPluginEventSystem.js#L313-L323 (opens in a new tab) ↩ -
scheduleTaskForRootDuringMicrotask(SyncLane: no Scheduler task) — packages/react-reconciler/src/ReactFiberRootScheduler.js#L384-L509 (opens in a new tab) ↩ -
flushSyncWorkAcrossRoots_impl— packages/react-reconciler/src/ReactFiberRootScheduler.js#L185-L247 (opens in a new tab) ↩ -
flushSpawnedWork: sync-lane passive effects flushed at end of commit — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4316-L4329 (opens in a new tab) ↩