⚛ The Course  /  Module 5 · A Full Update, End to End · 5.4
Module 5 · A Full Update, End to End

5.4 Events: a click becomes a SyntheticEvent

You write onClick={handleClick} on a button and your function just runs when someone clicks. But React never actually attached a listener to that button. So what happens in the gap between the real browser click and your handler firing — and why does a setState inside it feel instant? This lesson follows one click all the way through.

One listener at the root, not one per button

The first surprise: React does not call addEventListener on your <button>. Not once.

Instead, when createRoot() runs at startup, React attaches a single listener per event type to the root container — the <div id="root"> your whole app lives in. Every onClick anywhere in the tree shares that one root-level click listener.

Why bother? Because the alternative does not scale. A list with a thousand rows would mean a thousand real listeners. With delegation, a thousand rows still cost exactly one.

Native JavaScript (one listener per element):
  document
  ├── <div>
  │   ├── <button> ← addEventListener here
  │   └── <button> ← addEventListener here
  └── ...  (1 000 elements = 1 000 listeners)

React (one listener at root):
  <div id="root"> ← ONE listener here per event type
  ├── <div>
  │   ├── <button>  ← NO listener (only __reactProps$ data)
  │   └── <button>  ← NO listener (only __reactProps$ data)
  └── ...  (1 000 elements = still 1 listener)

That registration happens once, in listenToAllSupportedEvents. Notice it loops over every native event name and registers each one twice — once for the bubble phase, once for capture. (We will come back to those two phases shortly.)

The real code

function listenToAllSupportedEvents(rootContainerElement) {
  if (!rootContainerElement[listeningMarker]) {
    rootContainerElement[listeningMarker] = true;
    allNativeEvents.forEach(function (domEventName) {
      if (domEventName !== 'selectionchange') {
        if (!nonDelegatedEvents.has(domEventName)) {
          listenToNativeEvent(domEventName, false, rootContainerElement); // bubble
        }
        listenToNativeEvent(domEventName, true, rootContainerElement);   // capture
      }
    });
  }
}

Your handler is just data on the node

If the button has no listener, where does your handleClick live? It is stored as a plain property on the button's DOM node — data, not an event registration.

When React creates or updates a host element, it writes two private keys onto the node. One points back to the Fiber that owns the node; the other holds the node's current props (which is where onClick sits).

The real code

// Two private keys written onto every DOM element React creates:
var internalInstanceKey = '__reactFiber$'  + randomKey; // DOM → Fiber
var internalPropsKey    = '__reactProps$'  + randomKey; // DOM → current props

function precacheFiberNode(hostInst, node) {
  node[internalInstanceKey] = hostInst; // lets React find the fiber from any DOM node
}

function updateFiberProps(node, props) {
  node[internalPropsKey] = props;       // { onClick: handler, children: 'Click', ... }
  // NOT an event listener — just a plain property on the DOM node
}

Keep these two keys in mind — the whole dispatch process is just React reading them back off the DOM node when a click arrives:

PropertyStored byDirectionPurpose
__reactFiber$precacheFiberNode()DOM → Fiber"Which component owns this node?"
__reactProps$updateFiberProps()DOM → Props"What is the current onClick?"

The object your handler receives: a SyntheticEvent

The e in handleClick(e) is not the raw browser MouseEvent. It is a SyntheticEvent — React's own wrapper around the native event. It normalizes browser differences so that preventDefault(), stopPropagation(), and the common properties behave the same everywhere.

React does not write a separate class for every kind of event. It has one factory, createSyntheticEvent(Interface), and hands it a small "shopping list" — the Interface — naming which native properties this kind of event should expose. The factory stamps out a constructor that copies exactly those properties off the native event.

The real code

function createSyntheticEvent(Interface) {
  function SyntheticBaseEvent(reactName, type, targetInst, nativeEvent, target) {
    this.type        = type;        // 'click'
    this.nativeEvent = nativeEvent; // raw browser event
    this.target      = target;      // element clicked
    this.currentTarget = null;      // updated as React calls each handler

    for (const name in Interface) {
      const normalize = Interface[name];
      this[name] = normalize ? normalize(nativeEvent) // some props get fixed up
                             : nativeEvent[name];      // most copied as-is
    }
  }
  // shared methods: preventDefault(), stopPropagation(), persist(), isPersistent()
  return SyntheticBaseEvent;
}

// The "shopping lists" build on each other:
// EventInterface      → { timeStamp, bubbles, cancelable, ... }
// UIEventInterface    → EventInterface + { view, detail }
// MouseEventInterface → UIEventInterface + { clientX, clientY, button, ctrlKey, ... }

So a click hands you a SyntheticMouseEvent (it has clientX/clientY), a keydown hands you a SyntheticKeyboardEvent, and so on — but all of them come from this same factory, just with a different shopping list.

Dispatch: walking the Fiber tree to collect handlers

Now the click actually fires. It bubbles up to the root listener, and React runs four steps:

  1. Find the fiber — read nativeEvent.target.__reactFiber$ to get the clicked element's fiber.
  2. Collect handlers — walk fiber.return upward to root, gathering every onClick from HostComponent fibers.
  3. Build a SyntheticEvent — wrap the raw browser event (the factory above).
  4. Execute the list — run handlers in order, stopping early if isPropagationStopped() is set.

Step 2 is the heart of it. React starts at the clicked fiber and walks the return pointers up to the root, and at each HostComponent it reads the handler straight off that node's stored props. Everything is collected into one array before any handler runs.

The real code — step 2 (collecting)

function accumulateSinglePhaseListeners(targetFiber, reactName, inCapturePhase) {
  const name = inCapturePhase ? reactName + 'Capture' : reactName; // 'onClickCapture' vs 'onClick'
  const listeners = [];
  let fiber = targetFiber;
  while (fiber !== null) {            // walk target → root
    if (fiber.tag === HostComponent) {
      const handler = getListener(fiber, name); // reads fiber.stateNode.__reactProps$.onClick
      if (handler) listeners.push({ listener: handler, currentTarget: fiber.stateNode });
    }
    fiber = fiber.return;             // go to parent fiber
  }
  return listeners;
}

That getListener is the live read off the DOM node — it never holds a stale function, because it looks up the handler from the current props at the instant the event fires:

The real code

function getListener(inst, registrationName) {
  var stateNode = inst.stateNode;
  if (stateNode === null) return null;
  var props = getFiberCurrentPropsFromNode(stateNode); // reads node.__reactProps$
  if (props === null) return null;
  return props[registrationName]; // e.g. props['onClick']
}

Then step 4 runs the collected list in order, calling each handler — unless propagation was stopped:

The real code — step 4 (executing)

function processDispatchQueueItemsInOrder(event, listeners) {
  let previousInstance;
  for (let i = 0; i < listeners.length; i++) {
    const { instance, listener, currentTarget } = listeners[i];
    if (instance !== previousInstance && event.isPropagationStopped()) {
      return; // skip all remaining (ancestor) handlers
    }
    executeDispatch(event, listener, currentTarget);
    previousInstance = instance;
  }
}
listeners = [
  { instance: button, listener: onBtn, currentTarget: <button> },
  { instance: div,    listener: onDiv, currentTarget: <div> },
]

If onBtn calls e.stopPropagation():
  i=0  → instance=button, flag not set → run onBtn → flag set to true
  i=1  → button !== div AND isPropagationStopped() → return
  onDiv is never called

One more detail before we leave dispatch. Remember the root got two listeners — bubble and capture. React runs the collect-and-execute pass twice: it gathers onClickCapture handlers and runs them root→target, then gathers onClick handlers and runs them target→root. The order always matches the native DOM two-phase model.

Show the capture-vs-bubble example and its output
function App() {
  return (
    <div
      onClick={() => console.log('Div BUBBLE')}
      onClickCapture={() => console.log('Div CAPTURE')}
    >
      <button
        onClick={() => console.log('Button BUBBLE')}
        onClickCapture={() => console.log('Button CAPTURE')}
      >
        Click
      </button>
    </div>
  );
}
// Output:
// 1. Div CAPTURE
// 2. Button CAPTURE
// 3. Button BUBBLE
// 4. Div BUBBLE

Event type decides the lane

Here is the connection back to everything in this module. When your handler calls setState, it gets a lane — and that lane was chosen by the kind of event you are inside.

React sorts native events into three priority classes, and those priority constants are the lane values. A click is discrete (each one is distinct and must not be dropped) → SyncLane. A mousemove is continuous (fires rapidly, fine to coalesce) → InputContinuousLane. Everything else gets the default.

The real code

// Event priority constants (these ARE the lane values):
export const DiscreteEventPriority  = SyncLane;            // 0b0001
export const ContinuousEventPriority = InputContinuousLane; // 0b0100
export const DefaultEventPriority   = DefaultLane;          // 0b10000

export function getEventPriority(domEventName) {
  switch (domEventName) {
    // Discrete — each occurrence is distinct, must not be dropped
    case 'click':
    case 'keydown':
    case 'keyup':
    case 'mousedown':
    case 'mouseup':
    case 'touchstart':
    case 'input':
    case 'change':
    case 'submit':
      return DiscreteEventPriority;   // SyncLane

    // Continuous — high rate, OK to coalesce
    case 'mousemove':
    case 'scroll':
    case 'drag':
    case 'dragover':
    case 'touchmove':
    case 'wheel':
      return ContinuousEventPriority; // InputContinuousLane

    default:
      return DefaultEventPriority;    // DefaultLane
  }
}

The clever part: this priority is chosen once, at registration time, not at click time. When createRoot() wires up each native listener, it picks a different wrapper function based on the event's priority. When the event later fires, that wrapper sets a global "current update priority" before dispatching, and restores it after.

The real code

function createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags) {
  const eventPriority = getEventPriority(domEventName);
  switch (eventPriority) {
    case DiscreteEventPriority:
      return dispatchDiscreteEvent;   // sets SyncLane context before dispatching
    case ContinuousEventPriority:
      return dispatchContinuousEvent; // sets InputContinuousLane context
    default:
      return dispatchEvent;           // no priority override
  }
}

function dispatchDiscreteEvent(domEventName, eventSystemFlags, container, nativeEvent) {
  const previousPriority = getCurrentUpdatePriority();
  try {
    setCurrentUpdatePriority(DiscreteEventPriority); // <-- priority applied HERE
    dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
  } finally {
    setCurrentUpdatePriority(previousPriority);       // restore
  }
}

So by the time your handler runs and calls setState, the global priority context is already set to SyncLane. Your setState never asks for a lane explicitly — dispatchSetState calls requestUpdateLane, which simply reads the context the wrapper put there. The lane is inherited from the event.

The real code

function dispatchSetState(fiber, queue, action) {
  const lane = requestUpdateLane(fiber); // reads global priority context set by the wrapper
  // 'click' context → SyncLane (0b0001)

  const update = { lane, action, next: null };
  enqueueUpdate(fiber, queue, update, lane);

  const root = scheduleUpdateOnFiber(fiber, lane);
  if (root !== null) {
    ensureRootIsScheduled(root); // triggers scheduling
  }
}

And the lane is exactly what decides when the render happens — the story from 5.1 and 5.2:

EventPriorityLaneRender timing
click, keydownDiscreteSyncLaneFlushed synchronously in the same task — no Scheduler involved
mousemove, scrollContinuousInputContinuousLaneScheduled with high priority, can be batched across frames
defaultDefaultDefaultLaneScheduled at normal priority
startTransitionTransitionTransitionLaneInterruptible; yields to any discrete or continuous work

That last row is the escape hatch: startTransition temporarily swaps the priority context while its callback runs, so a setState inside it gets TransitionLane instead of the surrounding event's lane — overriding the inheritance on purpose.

The whole journey, one click

Putting all five pieces together — root listener, props-as-data, the SyntheticEvent factory, the fiber walk, and the inherited lane — here is a single click from the user's finger to the committed DOM:

Show the full end-to-end flow
User clicks <button>
    |
    v
Browser fires native event; it bubbles to <div id="root">
    |
    v
createEventListenerWrapperWithPriority chose wrapper at registration time:
  'click' -> dispatchDiscreteEvent
    |
    v
dispatchDiscreteEvent:
  setCurrentUpdatePriority(DiscreteEventPriority)  <-- SyncLane context set
  dispatchEvent(...)
    |
    v
dispatchEvent:
  nativeEvent.target.__reactFiber$ -> target Fiber
  dispatchEventsForPlugins(...)
    |
    v
accumulateSinglePhaseListeners:
  walk fiber.return chain upward
  read __reactProps$.onClick from each HostComponent
  build listeners = [buttonHandler, divHandler, ...]
    |
    v
Create SyntheticEvent (wraps nativeEvent, no pooling)
    |
    v
processDispatchQueueItemsInOrder:
  for each listener:
    check isPropagationStopped() -> break if true
    call listener(syntheticEvent)
      -> your handler runs: setState(...)
         requestUpdateLane reads SyncLane context
         update enqueued with SyncLane
    |
    v
setCurrentUpdatePriority(previousPriority)  <-- context restored
    |
    v
ensureRootIsScheduled:
  SyncLane present -> flushSyncCallbacks() (no Scheduler task)
    |
    v
Render (renderLanes = SyncLane) -> Commit -> DOM updated