React's event system
Follow a click through react-dom 19.3.1: the root's listeners, plugins, the fiber walk, SyntheticEvent, priorities that set the lane, and portals.
- 01Trace a click into react-domFollow a click from the root's listeners through dispatchEvent, the plugins, and the fiber walk to your onClick.
- 02Predict the order of every listenerSay when React's handlers run next to native listeners, and what stopPropagation stops on each side.
- 03Explain an event's priorityRead getEventPriority and say which lane a click, a mouse move or a timer gives an update, and when it renders.
One click, followed into react-dom
When you write onClick, it looks as if React puts a click listener on that button. The Responding to events lesson said it doesn't. This shop proves it. The spy.js tab records every addEventListener call from the moment it loads. Press Count the listeners.
import { useState } from "react";import { listenersOn } from "./spy.js"; export default function App() { const [cart, setCart] = useState(0); const [report, setReport] = useState(""); function countListeners() { const root = document.getElementById("root"); const onRoot = listenersOn(root); const kinds = new Set(onRoot.map((entry) => entry.type)); const onButtons = [...document.querySelectorAll("button")].flatMap(listenersOn); setReport(`#root: ${onRoot.length} listeners for ${kinds.size} kinds of event. The buttons: ${onButtons.length}.`); } return ( <> <button onClick={() => setCart(cart + 1)}>Add to cart ({cart})</button> <button onClick={countListeners}>Count the listeners</button> <p>{report}</p> </> );}In spy.js, lines 6 to 10 replace addEventListener on every element with a version that notes the element, the event and the phase (line 8), then adds the listener as usual. App.jsx imports it, and the preview's main.jsx loads App.jsx before it calls createRoot, so the spy is ready when react-dom starts listening. Line 10 of App.jsx collects the listeners on #root, and line 12 those on the two buttons. The page reads #root: 142 listeners for 87 kinds of event. The buttons: 0.
Both buttons have onClick, and neither has a listener. All of react-dom's listeners sit on #root, the element you passed to createRoot: 87 kinds of event, from clicks and keys to scrolls and media events, whether your app uses them or not. A click on the button bubbles up to #root, and react-dom works out from there which of your handlers to call. Using one listener on a parent for events on many children is called event delegation.
#root, where react-dom's listener runs dispatchEvent, which finds the button's fiber and calls your onClick.In a big office building, the post doesn't go to each office. It goes to one reception desk, which looks up who each parcel is for and calls them, then the people above them who asked to hear about it too. New offices don't need their own post box, and urgent parcels go first.
- In real life: All post goes to the reception desk, not to each office
- In React: Every click reaches react-dom's listener on #root
- In real life: The desk looks up who the parcel is for in the building's directory
- In React: getClosestInstanceFromNode finds the clicked element's fiber
- In real life: It calls the person, then their team lead, then the floor manager
- In React: Your handlers run from the clicked element up through its parents
- In real life: A parcel marked urgent is handed over before the rest
- In React: A click gets a higher priority than a mouse move
- In real life: A new floor in another building gets its own desk
- In React: A portal's container gets its own listeners
react-dom listens once, at the root, for every kind of event. When an event arrives, its listener finds the target's fiber, lets event plugins turn the browser's event into React events, collects your handlers by walking the fiber tree up from the target, and calls them with a SyntheticEvent. The kind of event also sets the priority, and so the lane, of the updates your handlers make.
Two listeners per event, on the root
The listeners are added by createRoot, near its end, in ReactDOMRoot.js, lines 254 to 258:
const rootContainerElement: Document | Element | DocumentFragment =
!disableCommentsAsDOMContainers && container.nodeType === COMMENT_NODE
? (container.parentNode as any)
: container;
listenToAllSupportedEvents(rootContainerElement);Lines 1 to 4 pick the element: your container, or its parent in the rare case where the container is a comment node. Line 5 hands it to listenToAllSupportedEvents:
export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
if (!(rootContainerElement as any)[listeningMarker]) {
(rootContainerElement as any)[listeningMarker] = true;
allNativeEvents.forEach(domEventName => {
// We handle selectionchange separately because it
// doesn't bubble and needs to be on the document.
if (domEventName !== 'selectionchange') {
if (!nonDelegatedEvents.has(domEventName)) {
listenToNativeEvent(domEventName, false, rootContainerElement);
}
listenToNativeEvent(domEventName, true, rootContainerElement);
}
});
const ownerDocument =
(rootContainerElement as any).nodeType === DOCUMENT_NODE
? rootContainerElement
: (rootContainerElement as any).ownerDocument;
// $FlowFixMe[invalid-compare]
if (ownerDocument !== null) {
// The selectionchange event also needs deduplication
// but it is attached to the document.
if (!(ownerDocument as any)[listeningMarker]) {
(ownerDocument as any)[listeningMarker] = true;
listenToNativeEvent('selectionchange', false, ownerDocument);
}
}
}
}- Lines 2 and 3: a hidden marker property, so a second call for the same element adds nothing.
- Line 4 loops over
allNativeEvents: every browser event that any event plugin registered when react-dom loaded. - Line 11 adds a capture listener for each event, and line 9 a bubble listener, except for the events in
nonDelegatedEvents. - Lines 14 to 25 put one
selectionchangelistener ondocument, the only place the browser fires it.
That explains the count: 87 kinds of event, each with a capture listener, and 55 of them with a bubble listener too. nonDelegatedEvents holds 32 events that don't bubble in the browser, like scroll, load and the media events. A root can still hear them in the capture phase, and when an element has onScroll, React adds a scroll listener to that element itself (ReactDOMComponent.js). This table reads the same spy for a few events:
import { listenersOn } from "./spy.js"; const kinds = ["click", "keydown", "input", "focusin", "mouseover", "scroll", "load", "focus", "mouseenter"]; export default function App() { const onRoot = listenersOn(document.getElementById("root")); const has = (type, capture) => onRoot.some((entry) => entry.type === type && entry.capture === capture); return ( <table> <thead> <tr><th>Event</th><th>Capture</th><th>Bubble</th></tr> </thead> <tbody> {kinds.map((type) => ( <tr key={type}> <td>{type}</td> <td>{has(type, true) ? "yes" : "no"}</td> <td>{has(type, false) ? "yes" : "no"}</td> </tr> ))} </tbody> </table> );}Line 7 checks the recorded listeners on #root for one event and one phase. click, keydown, input, focusin and mouseover say yes and yes. scroll and load say yes and no. focus and mouseenter say no and no: React builds onFocus from focusin, and onMouseEnter from mouseover and mouseout, as the plugins section shows.
React 17 moved these listeners from document to the root, so that two React apps, or two versions of React, can share a page. A few other things changed with it. How to read the table: each row asks one question of React 16 and of React 17 and later.
| The question | React 16 | React 17 and later |
|---|---|---|
| Where are React's listeners? | On document. | On the root container passed to createRoot, and on each portal's container. |
What does e.stopPropagation() in onClick stop? | Other React handlers. A native listener on document still ran. | Other React handlers, and native listeners above the root, such as on document. |
When do onClickCapture handlers run? | When the click reached document, just before the onClick handlers. | In the browser's capture phase, from React's capture listener on the root. |
| Is the event object reused? | Yes. Events were pooled, and e.persist() kept one. | No. Each dispatch makes a new object, and e.persist() does nothing. |
Does onScroll bubble? | Yes. React bubbled it to parents. | No, like the browser's own scroll event. |
What do onFocus and onBlur listen for? | focus and blur, in the capture phase. | focusin and focusout, which bubble. |
From the browser's event to your handler
What does each of those 142 listeners run? Each one is built by createEventListenerWrapperWithPriority, in ReactDOMEventListener.js, lines 94 to 119:
export function createEventListenerWrapperWithPriority(
targetContainer: EventTarget,
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
): Function {
const eventPriority = getEventPriority(domEventName);
let listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority:
listenerWrapper = dispatchDiscreteEvent;
break;
case ContinuousEventPriority:
listenerWrapper = dispatchContinuousEvent;
break;
case DefaultEventPriority:
default:
listenerWrapper = dispatchEvent;
break;
}
return listenerWrapper.bind(
null,
domEventName,
eventSystemFlags,
targetContainer,
);
}- Line 6 asks
getEventPriorityhow urgent this kind of event is. The priorities section reads it. - Lines 8 to 19 pick a wrapper for that priority. For a click, it's
dispatchDiscreteEvent. - Lines 20 to 25 bind the wrapper to the event's name, some flags (one of them says capture or bubble), and the container. The result is the function
addEventListenerreceives.
function dispatchDiscreteEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
container: EventTarget,
nativeEvent: AnyNativeEvent,
) {
const prevTransition = ReactSharedInternals.T;
ReactSharedInternals.T = null;
const previousPriority = getCurrentUpdatePriority();
try {
setCurrentUpdatePriority(DiscreteEventPriority);
dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
} finally {
setCurrentUpdatePriority(previousPriority);
ReactSharedInternals.T = prevTransition;
}
}Lines 7 and 8 set aside any transition that is running, so updates in your handler aren't treated as part of it. Line 11 sets the update priority to discrete while line 12 runs dispatchEvent, and lines 13 to 16 put both back. (See the file.) dispatchEvent finds the fiber of the element that was clicked with getClosestInstanceFromNode, which reads the __reactFiber$ key the fiber lesson found on every DOM element react-dom creates. Then dispatchEventForPluginEventSystem ends with this, at lines 694 to 702 of DOMPluginEventSystem.js:
batchedUpdates(() =>
dispatchEventsForPlugins(
domEventName,
eventSystemFlags,
nativeEvent,
ancestorInst,
targetContainer,
),
);batchedUpdates marks the time while your handlers run, so React renders their updates together afterwards. Inside it runs dispatchEventsForPlugins:
function dispatchEventsForPlugins(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
nativeEvent: AnyNativeEvent,
targetInst: null | Fiber,
targetContainer: EventTarget,
): void {
const nativeEventTarget = getEventTarget(nativeEvent);
const dispatchQueue: DispatchQueue = [];
extractEvents(
dispatchQueue,
domEventName,
targetInst,
nativeEvent,
nativeEventTarget,
eventSystemFlags,
targetContainer,
);
processDispatchQueue(dispatchQueue, eventSystemFlags);
}Line 8 gets the element the event happened on. Line 9 makes an empty dispatch queue. Lines 10 to 18 let the plugins fill it with pairs of a React event and the handlers that want it. Line 19 calls those handlers. The next three sections open each step.
react-dom-bindings/src/events; only the last card is your code.Before that, step through one click on a page with native listeners too. The order example further down logs the same lines; here you see which listener runs each one, and what react-dom calls inside its own two.
not startedwindowdocumenthtmlbody#rootdivbutton
0 linesThe order example's log lines appear here, one listener at a time.
The order example is on the page, with nothing stopped. Press Step to click Add to cart.
Plugins turn browser events into React events
React events don't match the browser's one for one. Event plugins do the matching. When react-dom loads, each registers the React props it supports and the browser events it needs (DOMPluginEventSystem.js, lines 93 to 100). Then extractEvents asks them, in order:
SimpleEventPlugin.extractEvents(
dispatchQueue,
domEventName,
targetInst,
nativeEvent,
nativeEventTarget,
eventSystemFlags,
targetContainer,
);
const shouldProcessPolyfillPlugins =
(eventSystemFlags & SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS) === 0;
// ... (comments left out)
if (shouldProcessPolyfillPlugins) {
EnterLeaveEventPlugin.extractEvents(
dispatchQueue,
domEventName,
targetInst,
nativeEvent,
nativeEventTarget,
eventSystemFlags,
targetContainer,
);Lines 1 to 9: SimpleEventPlugin always goes first. Lines 10, 11 and 13: the other plugins run only when this isn't the capture listener, so they see each event once. EnterLeaveEventPlugin is on line 14, and ChangeEventPlugin, SelectEventPlugin, BeforeInputEventPlugin and FormActionEventPlugin follow it the same way. Here is the simple plugin's work, in SimpleEventPlugin.js:
const reactName = topLevelEventsToReactNames.get(domEventName);
if (reactName === undefined) {
return;
}
let SyntheticEventCtor = SyntheticEvent;
let reactEventType: string = domEventName;
switch (domEventName) {
// ... (the switch that picks the event's class for each kind of event left out)
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
// ... (the onScroll rule and an experimental branch left out)
const listeners = accumulateSinglePhaseListeners(
targetInst,
reactName,
nativeEvent.type,
inCapturePhase,
accumulateTargetOnly,
nativeEvent,
);
if (listeners.length > 0) {
// Intentionally create event lazily.
const event: ReactSyntheticEvent = new SyntheticEventCtor(
reactName,
reactEventType,
null,
nativeEvent,
nativeEventTarget,
);
dispatchQueue.push({event, listeners});
}- Lines 1 to 4 look up the React name for the browser's:
clickbecomesonClick,focusinbecomesonFocus. An event with no simple name stops here. - Lines 5 to 7 start choosing the event's class, such as a mouse or keyboard event, by the event's name.
- Lines 11 to 18 collect the handlers, as the next section shows. Line 15 says which phase this listener is for.
- Lines 19 to 29: only if someone listens, make one
SyntheticEventand add it with its handlers to the queue. A click nobody handles makes no event object at all.
ChangeEventPlugin shows why plugins exist. It registers onChange with eight browser events, in ChangeEventPlugin.js, lines 35 to 46, and picks the right one for each kind of element, at lines 283 to 287:
function registerEvents() {
registerTwoPhaseEvent('onChange', [
'change',
'click',
'focusin',
'focusout',
'input',
'keydown',
'keyup',
'selectionchange',
]);
}if (shouldUseChangeEvent(targetNode)) {
getTargetInstFunc = getTargetInstForChangeEvent;
} else if (isTextInputElement(targetNode as any as HTMLElement)) {
if (isInputEventSupported) {
getTargetInstFunc = getTargetInstForInputOrChangeEvent;Lines 1 and 2: a select or a file input uses the browser's change. Lines 3 to 5: a text box uses input, or change, when its value really changed. Type tea in the box below, then press Done.
import { useEffect, useRef } from "react";import { note } from "./log.js"; export default function App() { const box = useRef(null); useEffect(() => { const input = box.current; const onNativeChange = () => note(`native change: "${input.value}"`); input.addEventListener("change", onNativeChange); return () => input.removeEventListener("change", onNativeChange); }, []); function handleChange(e) { note(`onChange: "${e.target.value}" (e.type ${e.type}, native ${e.nativeEvent.type})`); } return ( <> <input ref={box} onChange={handleChange} placeholder="Gift note" /> <button>Done</button> </> );}Line 15 logs once per key, three lines that end with onChange: "tea" (e.type change, native input). React calls the event change, but e.nativeEvent is the browser's input. The browser's own change, heard by lines 9 and 10, fires once, when you leave the box: native change: "tea". React's onChange doesn't fire again then, because the value didn't change since the last key. How to read the table: each row is a React prop, the browser events behind it, and the plugin that makes it.
| Your prop | The browser events React listens for | The plugin that makes it |
|---|---|---|
onClick | click | SimpleEventPlugin |
onDoubleClick | dblclick | SimpleEventPlugin |
onFocus and onBlur | focusin and focusout | SimpleEventPlugin |
onChange on a text box | input (and change, if the value changed) | ChangeEventPlugin |
onChange on a checkbox or radio | click | ChangeEventPlugin |
onChange on a select or a file input | change | ChangeEventPlugin |
onMouseEnter and onMouseLeave | mouseover and mouseout | EnterLeaveEventPlugin |
onClickon a buttononKeyDownon a text boxonFocuson a text boxonScrollon a listonChangeon a text boxonChangeon a checkboxonMouseEnteron a cardonPointerLeaveon a card
Put each prop under the plugin that turns browser events into it.
Collecting handlers by walking the fiber tree
The handlers come from accumulateSinglePhaseListeners, which walks from the target's fiber up to the root:
const captureName = reactName !== null ? reactName + 'Capture' : null;
const reactEventName = inCapturePhase ? captureName : reactName;
let listeners: Array<DispatchListener> = [];
let instance = targetFiber;
let lastHostComponent = null;
// Accumulate all instances and listeners via the target -> root path.
while (instance !== null) {
const {stateNode, tag} = instance;
// Handle listeners that are on HostComponents (i.e. <div>)
if (
(tag === HostComponent ||
tag === HostHoistable ||
tag === HostSingleton) &&
stateNode !== null
) {
lastHostComponent = stateNode;
// ... (createEventHandle listeners, an experimental API, left out)
// Standard React on* listeners, i.e. onClick or onClickCapture
if (reactEventName !== null) {
const listener = getListener(instance, reactEventName);
if (listener != null) {
listeners.push(
createDispatchListener(instance, listener, lastHostComponent),
);
}
}
// ... (scopes, another experimental API, and a rule for onScroll left out)
instance = instance.return;
}
return listeners;- Lines 1 and 2 pick the prop to look for:
onClick, oronClickCapturefor the capture listener. - Lines 9 and 30: a loop that climbs
return, the link to the parent fiber, until it passes the root. - Lines 12 to 17: only host fibers, the ones for DOM elements, with a DOM node. A component like
Appis never asked, even if it has a prop calledonClick. - Lines 21 to 27 read the handler with
getListenerand push it with its fiber and its DOM element. Line 32 returns the list, target first.
return up to the root. Only host fibers, the ones for DOM elements, can hold listeners, so App adds nothing. The bubble phase runs the list from the start, the capture phase from the end.getListener doesn't read the fiber's props. It reads the props stored on the DOM node:
export default function getListener(
inst: Fiber,
registrationName: string,
): Function | null {
const stateNode = inst.stateNode;
if (stateNode === null) {
// Work in progress (ex: onload events in incremental mode).
return null;
}
const props = getFiberCurrentPropsFromNode(stateNode);
if (props === null) {
// Work in progress.
return null;
}
// $FlowFixMe[invalid-computed-prop]
const listener = props[registrationName];
if (shouldPreventMouseEvent(registrationName, inst.type, props)) {
return null;
}
if (listener && typeof listener !== 'function') {
throw new Error(
`Expected \`${registrationName}\` listener to be a function, instead got a value of \`${typeof listener}\` type.`,
);
}
return listener;
}Line 10, getFiberCurrentPropsFromNode, returns the props react-dom saved on the element at the last commit, under a key that starts with __reactProps$. Line 16 picks the handler. Lines 17 to 19 ignore mouse handlers on a disabled button, input, select or textarea, and lines 21 to 25 throw if the prop isn't a function. The list then goes to processDispatchQueueItemsInOrder:
let previousInstance;
if (inCapturePhase) {
for (let i = dispatchListeners.length - 1; i >= 0; i--) {
const {instance, currentTarget, listener} = dispatchListeners[i];
if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}
// ... (a development-only branch that makes the same call left out)
executeDispatch(event, listener, currentTarget);
// ... (the end of that branch left out)
previousInstance = instance;
}
} else {
for (let i = 0; i < dispatchListeners.length; i++) {
const {instance, currentTarget, listener} = dispatchListeners[i];
if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}- Lines 2 and 3: the capture phase runs the list from its end, so the outermost element's handler goes first. Lines 13 and 14: the bubble phase runs it from the start, the target first.
- Lines 5 to 7 and 16 to 18: before moving to a handler on another element, stop if a handler called
stopPropagation. - Line 9 calls one handler through
executeDispatch:
function executeDispatch(
event: ReactSyntheticEvent,
listener: Function,
currentTarget: EventTarget,
): void {
event.currentTarget = currentTarget;
try {
listener(event);
} catch (error) {
reportGlobalError(error);
}
event.currentTarget = null;
}Line 6 sets currentTarget to this handler's element, line 8 calls your handler, and line 12 sets currentTarget back to null. Lines 9 and 10 report an error your handler throws instead of letting it stop the other handlers. (See the file.)
SyntheticEvent: a new object for every dispatch
The e your handler receives is made by SyntheticBaseEvent, in SyntheticEvent.js:
function SyntheticBaseEvent(
reactName: string | null,
reactEventType: string,
targetInst: Fiber | null,
nativeEvent: {[propName: string]: mixed, ...},
nativeEventTarget: null | EventTarget,
) {
this._reactName = reactName;
this._targetInst = targetInst;
this.type = reactEventType;
this.nativeEvent = nativeEvent;
this.target = nativeEventTarget;
this.currentTarget = null;
// ... (copying each field from the native event, and defaultPrevented, left out)
this.isPropagationStopped = functionThatReturnsFalse;
return this;
// ... (the start of the prototype, and preventDefault, left out)
stopPropagation: function () {
const event = this.nativeEvent;
if (!event) {
return;
}
if (event.stopPropagation) {
event.stopPropagation();
// ... (a fallback for Internet Explorer left out)
}
this.isPropagationStopped = functionThatReturnsTrue;
},
// ... (a comment about the old pool left out)
persist: function () {
// Modern event system doesn't use pooling.
},
// ... (a comment left out)
isPersistent: functionThatReturnsTrue,- Lines 8 to 13 store React's name for the event, the browser's event as
nativeEvent, and the target.currentTargetstarts asnull;executeDispatchfills it in. - Lines 18 to 30:
stopPropagationcalls the browser event's ownstopPropagation(line 25), and marks this object as stopped (line 29), whichprocessDispatchQueueItemsInOrderchecks. - Lines 32 to 34:
persistdoes nothing, and line 36:isPersistentalways says yes. Before React 17, event objects were reused, and you had to callpersistto keep one. Now each dispatch makes a new one.
Click the button and read what React handed the handler:
import { note } from "./log.js"; export default function App() { function handleClick(e) { note(`${e.constructor.name}: type ${e.type}, currentTarget ${e.currentTarget.tagName}`); note(`nativeEvent is a MouseEvent: ${e.nativeEvent instanceof MouseEvent}. isPersistent(): ${e.isPersistent()}`); const key = Object.keys(e.currentTarget).find((name) => name.startsWith("__reactProps$")); note(`The button's __reactProps$ holds this onClick: ${e.currentTarget[key].onClick === handleClick}`); setTimeout(() => note(`Later: type ${e.type}, target ${e.target.tagName}, currentTarget ${e.currentTarget}`)); } return <button onClick={handleClick}>Add to cart</button>;}Line 5 prints SyntheticBaseEvent: type click, currentTarget BUTTON. Line 6 prints that nativeEvent is a MouseEvent (in Chrome, a PointerEvent, which is a kind of MouseEvent) and isPersistent() is true. Lines 7 and 8 find the __reactProps$ key on the button and print that its onClick is this very function: that is what getListener read. Line 9 reads the event again after a timer: type click, target BUTTON, and currentTarget null. The object is still yours to read; only currentTarget was cleared by executeDispatch.
React's listeners and yours: who runs first
Because React's handlers run inside its listeners on #root, native listeners elsewhere run before or after them, following the browser's order. This example adds native listeners to document, the button and #root, next to React handlers on a div and the button. Click Add to cart, then pick where to stop the click and click again.
import { useEffect, useRef, useState } from "react";import { clearLog, note } from "./log.js"; export default function App() { const button = useRef(null); const [stopIn, setStopIn] = useState("nowhere"); useEffect(() => { const root = document.getElementById("root"); const listen = (target, name, capture) => { const listener = (e) => { if (name === "document, capture") clearLog(); note(`${name}: native`); if (stopIn === "the button's native listener" && target === button.current) e.stopPropagation(); }; target.addEventListener("click", listener, capture); return () => target.removeEventListener("click", listener, capture); }; const stops = [ listen(document, "document, capture", true), listen(button.current, "button", false), listen(root, "#root, bubble", false), listen(document, "document, bubble", false), ]; return () => stops.forEach((stop) => stop()); }, [stopIn]); return ( <> <label> Stop the click in:{" "} <select value={stopIn} onChange={(e) => setStopIn(e.target.value)}> <option>nowhere</option> <option>the button's onClick</option> <option>the button's native listener</option> </select> </label> <div onClickCapture={() => note("div onClickCapture: React")} onClick={() => note("div onClick: React")}> <button ref={button} onClickCapture={() => note("button onClickCapture: React")} onClick={(e) => { note("button onClick: React"); if (stopIn === "the button's onClick") e.stopPropagation(); }} > Add to cart </button> </div> </> );}Lines 19 to 24 add the four native listeners; line 12 clears the log when the first of them runs. Lines 38 to 48 are the React handlers. With nothing stopped, the log reads:
document, capture: native. The capture phase starts at the top, and document is above the root.div onClickCapture: React,button onClickCapture: React. React's capture listener on#root, outermost first.button: native. The click reaches its target.button onClick: React,div onClick: React. React's bubble listener on#root.#root, bubble: native, thendocument, bubble: native. The example added its#rootlistener aftercreateRootadded React's, so it runs second there.
#root: capture handlers on the way down, onClick handlers on the way back up, before the native listener the example added to #root later. Native listeners run where they sit.Pick the button's onClick: line 44 calls e.stopPropagation(). The log ends with button onClick: React and #root, bubble: native. React skips the div's onClick, and its call to the browser event's stopPropagation keeps the click from reaching document. The native listener on #root still runs, because stopping only keeps an event from moving on to other elements.
Pick the button's native listener: line 14 stops the click at the button. The log ends with button: native. Neither onClick runs, though the capture handlers already did: the click never reached React's bubble listener. A third-party script that stops clicks this way silences your onClick handlers.
Event priorities choose the update's lane
Back to line 6 of the wrapper: getEventPriority sorts every event name into a priority:
export function getEventPriority(domEventName: DOMEventName): EventPriority {
switch (domEventName) {
// Used by SimpleEventPlugin:
case 'beforetoggle':
case 'cancel':
case 'click':
// ... (more discrete events left out)
case 'input':
case 'invalid':
case 'keydown':
case 'keypress':
case 'keyup':
// ... (more discrete events left out)
return DiscreteEventPriority;
case 'drag':
// ... (drag events left out)
case 'mousemove':
// ... (more continuous events left out)
case 'scroll':
case 'touchmove':
case 'wheel':
// ... (pointer events and enter and leave events left out)
return ContinuousEventPriority;
// ... (the message event left out)
default:
return DefaultEventPriority;
}
}Lines 4 to 14: events that happen once per action, like click, input and keydown, are discrete. Lines 15 to 23: events that fire many times in a row, like mousemove, scroll and wheel, are continuous. Line 26: everything else is default. Each priority is a lane, in ReactEventPriorities.js, lines 25 to 28:
export const DiscreteEventPriority: EventPriority = SyncLane;
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
export const DefaultEventPriority: EventPriority = DefaultLane;
export const IdleEventPriority: EventPriority = IdleLane;When your handler calls a set function, requestUpdateLane picks the update's lane, a step the What setState really does lesson traced. It (ReactFiberWorkLoop.js, lines 863 to 867) asks resolveUpdatePriority and turns the answer into the update's lane:
export function resolveUpdatePriority(): EventPriority {
const updatePriority = ReactDOMSharedInternals.p; /* currentUpdatePriority */
if (updatePriority !== NoEventPriority) {
return updatePriority;
}
const currentEvent = window.event;
if (currentEvent === undefined) {
return DefaultEventPriority;
}
return getEventPriority(currentEvent.type);
}- Lines 2 to 5: the priority
dispatchDiscreteEventset while your handler runs. - Lines 6 to 10: if none is set, React looks at
window.event, the event the browser is dispatching right now. So even a nativeaddEventListenerclick handler gives its updates a discrete priority. - Line 8: outside any event, like in a timer, the priority is default.
The lane decides when React renders. A SyncLane update is rendered in a microtask right after your handler, before the browser does anything else; other lanes wait for a scheduler task. This example updates the count, then looks at the page in its own microtask. Try every control.
import { useEffect, useRef, useState } from "react";import { note } from "./log.js"; export default function App() { const [count, setCount] = useState(0); const counter = useRef(null); const nativeButton = useRef(null); // Updates the count, then looks at the page in a microtask. function bump(kind) { setCount((n) => n + 1); const before = counter.current.textContent; queueMicrotask(() => { const now = counter.current.textContent; note(`${kind}: ${now === before ? "not yet" : "already on screen"}`); }); } useEffect(() => { const button = nativeButton.current; const onClick = () => bump("native click listener"); button.addEventListener("click", onClick); return () => button.removeEventListener("click", onClick); }, []); return ( <> <p>Count: <span ref={counter}>{count}</span></p> <button onClick={() => bump("onClick")}>Click</button> <input onChange={() => bump("onChange")} placeholder="Type" /> <div onMouseMove={() => bump("onMouseMove")} className="pad">Move the mouse here</div> <div onWheel={() => bump("onWheel")} className="pad">Turn the wheel here</div> <button onClick={() => setTimeout(() => bump("a timer"))}>Timer</button> <button ref={nativeButton}>Native listener</button> </> );}Lines 10 to 17: bump calls setCount, then queues a microtask that compares the count on the page with the one before. Click, typing, and Native listener (lines 19 to 24) log already on screen: discrete events, SyncLane, rendered in React's microtask, which was queued first. Moving the mouse over the box, turning the wheel over the other one, and Timer log not yet: continuous and default lanes wait for a task. This is also why a click interrupts a transition and a timer doesn't, as the concurrent rendering lesson showed. How to read the table: each row is a group of events, the priority and lane their updates get, and when they render.
| Events | getEventPriority | The update's lane | When it renders |
|---|---|---|---|
click, keydown, input, submit, focusin | DiscreteEventPriority | SyncLane | In a microtask, before the browser's next task. |
mousemove, wheel, scroll, pointermove, dragover | ContinuousEventPriority | InputContinuousLane | In a scheduler task, soon after the event. |
| Any other event, or code outside an event: a timer, a fetch | DefaultEventPriority | DefaultLane | In a scheduler task. |
Portals: a second root that bubbles through the first
A portal puts DOM somewhere else, often straight into body. A click there never passes #root. So react-dom listens on the portal's container too, when the portal mounts, in ReactFiberConfigDOM.js, lines 845 to 847:
export function preparePortalMount(portalInstance: Instance): void {
listenToAllSupportedEvents(portalInstance);
}Open the size guide below, then close it.
import { useState } from "react";import { createPortal } from "react-dom";import { listenersOn } from "./spy.js";import { note } from "./log.js"; const modalRoot = document.body.appendChild(document.createElement("div"));modalRoot.id = "modal-root"; function SizeGuide({ onClose }) { return createPortal( <div className="modal"> <p>Sizes: S, M, L</p> <button onClick={onClose}>Close</button> </div>, modalRoot, );} export default function App() { const [open, setOpen] = useState(false); function openGuide() { setOpen(true); setTimeout(() => note(`#modal-root has ${listenersOn(modalRoot).length} listeners`)); } function close() { note("Close's onClick"); setOpen(false); } return ( <section onClick={() => note("the product card's onClick")}> <h3>Linen shirt</h3> <button onClick={openGuide} disabled={open}>Size guide</button> {open && <SizeGuide onClose={close} />} </section> );}Lines 6 and 7 add a #modal-root to the page, and lines 10 to 16 render the guide into it. Size guide logs the product card's onClick, from line 33, then line 24 reports #modal-root has 142 listeners: the same set as #root. Close logs Close's onClick, then the product card's onClick. In the DOM, Close is in body, nowhere near the card. But the click's fiber walk follows return links, and a portal's return is the component that rendered it, inside the card. Open the guide again: the count stays at 142, because the marker from listenToAllSupportedEvents stops a second set.
One more check keeps a click from running handlers twice when a portal's container sits inside another root's container. Before the walk, dispatchEventForPluginEventSystem climbs from the target to the nearest root or portal, in DOMPluginEventSystem.js:
const nodeTag = node.tag;
if (nodeTag === HostRoot || nodeTag === HostPortal) {
let container = node.stateNode.containerInfo;
if (isMatchingRootContainer(container, targetContainerNode)) {
break;
}
if (nodeTag === HostPortal) {
// ... (comments left out)
let grandNode = node.return;
while (grandNode !== null) {
const grandTag = grandNode.tag;
if (grandTag === HostRoot || grandTag === HostPortal) {
const grandContainer = grandNode.stateNode.containerInfo;
if (
isMatchingRootContainer(grandContainer, targetContainerNode)
) {
// This is the rootContainer we're looking for and we found it as
// a parent of the Portal. That means we can ignore it because the
// Portal will bubble through to us.
return;
}- Line 2: a root or a portal.
- Lines 4 to 6: its container is the one whose listener is running, as for our guide and
#modal-root. Good: go on from the target. - Lines 7 to 20: a portal whose container is not this listener's. If this listener's container is further up, the outer listener returns at line 20 and leaves the click to the portal container's listener, which bubbles through the React tree anyway.
A menu that closes the moment it opens
Here is a bug that comes straight from this machinery. A sort menu opens on click and closes when you click outside it, with a native listener on document added while it's open. Click Sort by.
import { useEffect, useRef, useState } from "react";import { note } from "./log.js"; export default function App() { const [open, setOpen] = useState(false); const menu = useRef(null); useEffect(() => { if (!open) return; note("effect: listen for clicks on document"); function closeOnOutsideClick(e) { if (menu.current.contains(e.target)) return; note("document heard a click outside: close"); setOpen(false); } document.addEventListener("click", closeOnOutsideClick); return () => document.removeEventListener("click", closeOnOutsideClick); }, [open]); function openMenu() { note("onClick: open the menu"); setOpen(true); } return ( <> <button onClick={openMenu}>Sort by</button> {open && ( <ul ref={menu} className="menu"> <li>Price</li> <li>Rating</li> </ul> )} </> );}In a browser, the menu never shows, and the log reads onClick: open the menu, effect: listen for clicks on document, document heard a click outside: close. The click that opened the menu also closed it. Follow it:
- The click reaches React's bubble listener on
#root, andopenMenu(lines 20 to 23) callssetOpen(true). A click is discrete, so the update getsSyncLane. - When a listener returns, the browser runs the microtasks waiting. React's microtask renders and commits the open menu at once, before the click moves on to
document. - Effects usually run after the browser paints. Not after a
SyncLanecommit: the end of the commit runs them straight away.
// If the passive effects are the result of a discrete render, flush them
// synchronously at the end of the current task so that the result is
// immediately observable. Otherwise, we assume that they are not
// order-dependent and do not need to be observed by external systems, so we
// can wait until after paint.
// TODO: We can optimize this by not scheduling the callback earlier. Since we
// currently schedule the callback in multiple places, will wait until those
// are consolidated.
if (
includesSyncLane(pendingEffectsLanes) &&
(disableLegacyMode || root.tag !== LegacyRoot)
) {
flushPendingEffects();
}Lines 9 to 13: the pending effects include SyncLane, so flushPendingEffects runs them now. The effect on lines 8 to 18 adds its document listener, and the same click, still on its way up, reaches document and runs it. Line 12 checks whether the click was inside the menu, but the click was on the Sort by button, outside the <ul>. So it closes.
A click your code sends with dispatchEvent or click() doesn't show the bug: microtasks wait until that call returns, so the effect runs after the click has passed document. A test that clicks this way passes while real users see a broken menu. The fix is to treat the button as part of the menu, as the menu's users do:
import { useEffect, useRef, useState } from "react";import { note } from "./log.js"; export default function App() { const [open, setOpen] = useState(false); const dropdown = useRef(null); useEffect(() => { if (!open) return; note("effect: listen for clicks on document"); function closeOnOutsideClick(e) { if (dropdown.current.contains(e.target)) return; note("document heard a click outside: close"); setOpen(false); } document.addEventListener("click", closeOnOutsideClick); return () => document.removeEventListener("click", closeOnOutsideClick); }, [open]); function openMenu() { note("onClick: open the menu"); setOpen(true); } return ( <div ref={dropdown}> <button onClick={openMenu}>Sort by</button> {open && ( <ul className="menu"> <li>Price</li> <li>Rating</li> </ul> )} </div> );}Line 26 puts the ref on a div around both the button and the menu, and line 12 ignores clicks inside it. The opening click is now inside, so the menu stays open; a click anywhere else closes it. Listening for pointerdown instead of click also works, because the press that starts the opening click happens before the listener exists.
Common mix-ups
- "React adds a listener to every element with onClick." No. It adds a capture and a bubble listener per kind of event, once, to the root container.
- "e is the browser's event." No. It's a
SyntheticBaseEvent; the browser's ise.nativeEvent. - "You must call e.persist() to read e later." No. Since React 17 there's no pooling. Only
currentTargetis cleared after your handler. - "onChange is the browser's change event." No. On a text box, ChangeEventPlugin fires it on
input, every key. - "A native stopPropagation can't affect React handlers." No. Stopped below the root, a click never reaches React's bubble listener, so no
onClickruns. - "onClickCapture runs before every native listener." No. Capture listeners above the root, like on
document, run first. - "Portal clicks bubble to React parents through the DOM." No. The portal's container has its own listeners, and the fiber walk climbs through the portal to its React parent.
Practice exercises
The button has a native click listener and an onClick. Predict the first line of the log.
import { useEffect, useRef } from "react";
import { note } from "./log.js";
export default function App() {
const button = useRef(null);
useEffect(() => {
const node = button.current;
const onNativeClick = () => note("native listener");
node.addEventListener("click", onNativeClick);
return () => node.removeEventListener("click", onNativeClick);
}, []);
return (
<button ref={button} onClick={() => note("onClick")}>
Add to cart
</button>
);
}import { useEffect, useRef } from "react";
import { note } from "./log.js";
export default function App() {
const button = useRef(null);
useEffect(() => {
const node = button.current;
const onNativeClick = () => note("native listener");
node.addEventListener("click", onNativeClick);
return () => node.removeEventListener("click", onNativeClick);
}, []);
return (
<button ref={button} onClick={() => note("onClick")}>
Add to cart
</button>
);
}The native listener sits on the button, and the click reaches the button first. onClick runs later, from React's bubble listener on #root: native listener, then onClick.
Predict the second line of the log after one click.
import { note } from "./log.js";
export default function App() {
function handleClick(e) {
note(`now: ${e.currentTarget.tagName}`);
setTimeout(() => note(`later: ${e.currentTarget}`));
}
return <button onClick={handleClick}>Add to cart</button>;
}import { note } from "./log.js";
export default function App() {
function handleClick(e) {
note(`now: ${e.currentTarget.tagName}`);
setTimeout(() => note(`later: ${e.currentTarget}`));
}
return <button onClick={handleClick}>Add to cart</button>;
}During the handler, currentTarget is the button. executeDispatch sets it to null when the handler returns, so the timer reads later: null. The object itself isn't reused.
Finish the replacement addEventListener so React's click listeners on #root log when they run. What three lines does one click give?
import { note } from "./log.js"; // TODO: wrap `listener` so that, for a click listener on #root, it first// notes "#root capture listener runs" or "#root bubble listener runs".const addEventListener = EventTarget.prototype.addEventListener;EventTarget.prototype.addEventListener = function (type, listener, options) { return addEventListener.call(this, type, listener, options);}; export default function App() { return <button onClick={() => note("onClick runs")}>Add to cart</button>;}import { note } from "./log.js"; const addEventListener = EventTarget.prototype.addEventListener;EventTarget.prototype.addEventListener = function (type, listener, options) { const capture = options === true || options?.capture === true; if (type === "click" && this.id === "root") { const original = listener; listener = (event) => { note(`#root ${capture ? "capture" : "bubble"} listener runs`); return original(event); }; } return addEventListener.call(this, type, listener, options);}; export default function App() { return <button onClick={() => note("onClick runs")}>Add to cart</button>;}React's capture listener on #root runs on the way down, and its bubble listener on the way up; your wrappers note each one before it runs. onClick runs inside the bubble listener, so it logs last: #root capture listener runs, #root bubble listener runs, onClick runs.
Finish accumulate so it climbs from the button and keeps host fibers' handlers only. What does it print?
const log = [];
const root = { tag: "HostRoot", props: {}, return: null };
// A component's props are never event listeners, even one named onClick.
const app = { tag: "FunctionComponent", props: { onClick: () => log.push("App onClick") }, return: root };
const card = {
tag: "HostComponent",
props: { onClick: () => log.push("card onClick"), onClickCapture: () => log.push("card onClickCapture") },
return: app,
};
const button = {
tag: "HostComponent",
props: { onClick: () => log.push("button onClick"), onClickCapture: () => log.push("button onClickCapture") },
return: card,
};
// Runs the listeners like processDispatchQueue: from the end in the capture phase.
function dispatch(listeners, inCapturePhase) {
const order = inCapturePhase ? [...listeners].reverse() : listeners;
for (const listener of order) listener();
}
// TODO: walk from `fiber` up through `return`, and collect props[name]
// from host fibers only, like accumulateSinglePhaseListeners.
function accumulate(fiber, name) {
const listeners = [];
return listeners;
}
dispatch(accumulate(button, "onClickCapture"), true);
dispatch(accumulate(button, "onClick"), false);
console.log(log.join(", "));const log = [];
const root = { tag: "HostRoot", props: {}, return: null };
// A component's props are never event listeners, even one named onClick.
const app = { tag: "FunctionComponent", props: { onClick: () => log.push("App onClick") }, return: root };
const card = {
tag: "HostComponent",
props: { onClick: () => log.push("card onClick"), onClickCapture: () => log.push("card onClickCapture") },
return: app,
};
const button = {
tag: "HostComponent",
props: { onClick: () => log.push("button onClick"), onClickCapture: () => log.push("button onClickCapture") },
return: card,
};
// Runs the listeners like processDispatchQueue: from the end in the capture phase.
function dispatch(listeners, inCapturePhase) {
const order = inCapturePhase ? [...listeners].reverse() : listeners;
for (const listener of order) listener();
}
function accumulate(fiber, name) {
const listeners = [];
for (let instance = fiber; instance !== null; instance = instance.return) {
if (instance.tag === "HostComponent" && instance.props[name]) listeners.push(instance.props[name]);
}
return listeners;
}
dispatch(accumulate(button, "onClickCapture"), true);
dispatch(accumulate(button, "onClick"), false);
console.log(log.join(", "));The walk finds the button's handler, then the card's, and skips App, a component. The capture list runs from its end and the bubble list from its start: card onClickCapture, button onClickCapture, button onClick, card onClick.
Click Sort by: the menu closes at once. Fix it so it stays open until a click outside. What is the last log line after opening it?
import { useEffect, useRef, useState } from "react";import { note } from "./log.js"; export default function App() { const [open, setOpen] = useState(false); const menu = useRef(null); useEffect(() => { if (!open) return; note("effect: listen for clicks on document"); function closeOnOutsideClick(e) { if (menu.current.contains(e.target)) return; note("document heard a click outside: close"); setOpen(false); } document.addEventListener("click", closeOnOutsideClick); return () => document.removeEventListener("click", closeOnOutsideClick); }, [open]); function openMenu() { note("onClick: open the menu"); setOpen(true); } return ( <> <button onClick={openMenu}>Sort by</button> {open && ( <ul ref={menu} className="menu"> <li>Price</li> <li>Rating</li> </ul> )} </> );}import { useEffect, useRef, useState } from "react";import { note } from "./log.js"; export default function App() { const [open, setOpen] = useState(false); const dropdown = useRef(null); useEffect(() => { if (!open) return; note("effect: listen for clicks on document"); function closeOnOutsideClick(e) { if (dropdown.current.contains(e.target)) return; note("document heard a click outside: close"); setOpen(false); } document.addEventListener("click", closeOnOutsideClick); return () => document.removeEventListener("click", closeOnOutsideClick); }, [open]); function openMenu() { note("onClick: open the menu"); setOpen(true); } return ( <div ref={dropdown}> <button onClick={openMenu}>Sort by</button> {open && ( <ul className="menu"> <li>Price</li> <li>Rating</li> </ul> )} </div> );}With the ref on a div around the button and the menu, the opening click is inside it, so closeOnOutsideClick returns early. The log stops at effect: listen for clicks on document, and the menu stays open.
A library stops every click inside its box, so Add to cart never counts. Without touching the library's effect, make one click count. What does the button say?
import { useEffect, useRef, useState } from "react"; export default function App() { const [count, setCount] = useState(0); const box = useRef(null); // A widget library stops every click inside its box. useEffect(() => { const node = box.current; const stop = (e) => e.stopPropagation(); node.addEventListener("click", stop); return () => node.removeEventListener("click", stop); }, []); return ( <div ref={box}> <button onClick={() => setCount(count + 1)}>Add to cart ({count})</button> </div> );}import { useEffect, useRef, useState } from "react"; export default function App() { const [count, setCount] = useState(0); const box = useRef(null); // A widget library stops every click inside its box. useEffect(() => { const node = box.current; const stop = (e) => e.stopPropagation(); node.addEventListener("click", stop); return () => node.removeEventListener("click", stop); }, []); return ( <div ref={box}> <button onClickCapture={() => setCount(count + 1)}>Add to cart ({count})</button> </div> );}onClickCapture runs from React's capture listener on #root, on the way down, before the widget's listener stops the click on the way up. One click shows Add to cart (1).
Check your understanding
Question 1 of 8Where does React 19 add the listener that ends up calling your
onClick?Choose an answer to see the explanation.
Question 2 of 8A button's native click listener calls
e.stopPropagation(). What happens to the button's ReactonClick?Choose an answer to see the explanation.
Question 3 of 8Your
onClickcallse.stopPropagation(). Does a native click listener ondocumentstill run?Choose an answer to see the explanation.
Question 4 of 8Which priority does
getEventPrioritygive amousemoveevent?Choose an answer to see the explanation.
Question 5 of 8In the capture phase, processDispatchQueue runs the list like this. What does it print?
Read the code, then predict// The onClickCapture handlers accumulateSinglePhaseListeners found, // walking up from the button. const found = ["button", "article", "section"]; const ran = []; for (let i = found.length - 1; i >= 0; i--) ran.push(found[i]); console.log(ran.join(" > "));Choose an answer to see the explanation.
Question 6 of 8Which browser event fires a text box's
onChangeon each key?Choose an answer to see the explanation.
Question 7 of 8A click inside a portal reaches a parent's
onClick, though the portal's DOM is inbody. Why?Choose an answer to see the explanation.
Question 8 of 8What does
e.persist()do in React 19?Choose an answer to see the explanation.
Key takeaways
createRootcallslistenToAllSupportedEvents: a capture listener and, for events that bubble, a bubble listener per kind of event, on the root container. Portals get the same on their container.- Each listener runs
dispatchEvent, which finds the target's fiber, lets the plugins build React events, and runs your handlers insidebatchedUpdates. - Plugins map browser events to props: SimpleEventPlugin for most, ChangeEventPlugin for
onChange, EnterLeaveEventPlugin for enter and leave. accumulateSinglePhaseListenersclimbsreturnlinks from the target and reads host fibers' props from the DOM node; capture runs the list from the end, bubble from the start.eis a newSyntheticBaseEventper dispatch, withnativeEventinside; no pooling since React 17.getEventPrioritymakes clicks and keys discrete (SyncLane), mouse moves and scrolls continuous, and the rest default.
React listens at the root, finds your handlers in the fiber tree, and gives each event a priority.
Native listeners run where they sit; React's run inside its two listeners on the root.
Coming next: Hydration inside. It shows how React claims HTML that is already on the page, including the events that arrive before it has.