| |

React 107 ⚛️ Synthetic Event Pooling and Event Delegation

⏱️ Estimated read: 12 min | |

React does not attach event listeners to the DOM elements you write in JSX. It attaches a single listener per event type to the root container, and when a native event fires, React walks its internal Fiber tree to find the components that have handlers for that event, then calls them with a SyntheticEvent—a cross-browser wrapper around the native event. This is event delegation. The pooling of SyntheticEvent objects was a performance optimization that existed in React 16 and earlier, and was removed in React 17.

This chapter covers the delegation model, the location of the root listener and why it changed, the SyntheticEvent interface, and the pooling mechanism that was removed. It examines the practical consequences of each change for code that reads the event object asynchronously, and the specific scenarios where the React 17 behavior differs from React 16.

Key point: Event pooling no longer exists on the web. If you see e.persist() in a tutorial or a legacy codebase, it is a relic of React 16. In React 17 and later, you can access the event object asynchronously without any workaround.


Why delegation and pooling exist

The listener count problem. A table with 1,000 rows and a click handler on each row would, without delegation, require 1,000 DOM listeners. Each listener has a memory cost, and each one must be removed when the row is removed. React’s delegation model replaces the 1,000 listeners with one listener at the root and a mapping from the Fiber tree to the handler functions. The memory cost is the Fiber mapping, not the DOM listeners.

The dynamic element problem. When a row is added after the initial render, a direct listener approach requires attaching the listener to the new element. The delegation approach requires nothing: the new element’s handler is registered in the Fiber tree during render, and the root listener dispatches to it automatically. The delegation model is what makes <li onClick={...}> in a list work without re-attaching listeners when items are added or removed.

The allocation problem. The SyntheticEvent wrapper is a new object created for every event dispatch. In a high-frequency event like mousemove or scroll, this allocation creates garbage collection pressure. React 16’s pooling reused a small number of event objects by resetting their properties after each handler returned. The optimization was removed because the allocation cost was not the bottleneck it was assumed to be, and the asynchronous access failure it caused was a source of confusion.


a. Event Delegation: One Listener at the Root

When you write <button onClick={handler}>, React does not call button.addEventListener('click', handler). Instead, it registers the handler in an internal mapping keyed by the component’s Fiber node, and it attaches a single native listener for that event type to the root container.

The delegation model has two practical consequences. First, memory usage is lower: a table with 1,000 rows and a click handler on each row does not create 1,000 DOM listeners; it creates one listener at the root that dispatches to the correct row’s handler. Second, dynamically added elements work automatically: a row added after the initial render has its handler registered in the Fiber tree, and the root listener dispatches to it without any re-attachment.

The location of the root listener changed in React 17. Before React 17, the listener was attached to document. In React 17 and later, it is attached to the root container—the element passed to createRoot. The change was made to fix a real bug: when a non-React library attached a document-level listener and called stopPropagation, React’s listener on document would still receive the event, because both listeners were on the same node. Moving React’s listener to the root container made stopPropagation behave as expected for events inside the React tree.

stopPropagation inside a React handler stops the event from bubbling within React’s own dispatch mechanism. React collects the handlers along the Fiber path and invokes them in order; calling e.stopPropagation() prevents the remaining handlers in that collected path from running. It does not prevent the native event from reaching the root container, because the native event has already reached the root container by the time React dispatches it.


b. Synthetic Events: The Cross-Browser Wrapper

The object passed to a React event handler is a SyntheticEvent, not the native browser event. It has the same interface as the native event—stopPropagation(), preventDefault(), target, currentTarget, and so on—but it normalizes the behavior across browsers so that the handler does not need to branch on browser type.

The native event is available through the nativeEvent property if you need it. This is useful when integrating with a third-party library that expects the real DOM event, or when you need a property that the synthetic wrapper does not expose.

The SyntheticEvent wrapper is what makes React’s event system consistent across browsers. It is also what made pooling possible: because React created these wrapper objects for every event, it could reuse them instead of allocating a new one each time.


c. Event Pooling: A React 16 Artifact

In React 16 and earlier, the SyntheticEvent objects were pooled. After a handler returned, the event object was reused for the next event, and all its properties were set to null. The purpose was to reduce garbage collection pressure: instead of allocating and discarding an event object for every click, React kept a pool of objects and recycled them.

The consequence was that asynchronous access to the event failed. A setTimeout that read e.target.value after the handler returned would read null, because the object had already been recycled. The workaround was e.persist(), which removed the event from the pool and allowed it to be referenced asynchronously.

React 17 removed pooling entirely. The SyntheticEvent is no longer reused, and asynchronous access works without any special handling. The persist() method still exists for backwards compatibility, but it does nothing on the web in React 17 and later. In React Native, pooling remains in use, so persist() is still relevant there.

The removal of pooling was part of the React 17 release, which was focused on making it possible to run multiple versions of React on the same page rather than on new features. The delegation change (moving the listener from document to the root container) and the pooling removal were both changes to the event system that made the multi-version scenario work correctly.


Complete Example Session

// ============================================
// PART 1: DELEGATION — ONE LISTENER AT ROOT
// ============================================
function Table({ rows }) {
  // One handler per row in JSX, but only one native listener at the root
  return (
    <table>
      {rows.map(row => (
        <tr key={row.id} onClick={() => handleRowClick(row.id)}>
          <td>{row.name}</td>
        </tr>
      ))}
    </table>
  )
}
// React attaches one 'click' listener to the root container.
// The handler for the clicked row is found by walking the Fiber tree.


// ============================================
// PART 2: SYNTHETIC EVENT INTERFACE
// ============================================
function Button() {
  function handleClick(e) {
    // e is a SyntheticEvent, not the native MouseEvent
    console.log(e.type)           // 'click'
    console.log(e.target)         // the DOM element that was clicked
    console.log(e.nativeEvent)    // the native MouseEvent

    e.preventDefault()            // same as native
    e.stopPropagation()           // stops React's dispatch to ancestors
  }

  return <button onClick={handleClick}>Click</button>
}


// ============================================
// PART 3: STOPPROPAGATION IN REACT
// ============================================
function Toolbar() {
  return (
    <div onClick={() => alert('Toolbar clicked')}>
      <button
        onClick={(e) => {
          // Prevents the div's handler from running
          e.stopPropagation()
          alert('Button clicked')
        }}
      >
        Click
      </button>
    </div>
  )
}
// Clicking the button alerts "Button clicked" only.
// The div's handler does not run because stopPropagation
// halted React's dispatch along the Fiber path.


// ============================================
// PART 4: ASYNC ACCESS — NO persist() NEEDED
// ============================================
function AsyncAccess() {
  function handleChange(e) {
    // React 17+: this works without e.persist()
    setTimeout(() => {
      console.log(e.target.value) // the value is available
    }, 100)
  }

  return <input onChange={handleChange} />
}


// ============================================
// PART 5: LEGACY REACT 16 BEHAVIOR (DO NOT DO THIS)
// ============================================
function LegacyReact16() {
  function handleChange(e) {
    // In React 16, this would log null without e.persist()
    // e.persist() // only needed in React 16
    setTimeout(() => {
      console.log(e.target.value) // React 17+: works
    }, 100)
  }

  return <input onChange={handleChange} />
}

The five parts trace the delegation model, the synthetic event interface, the stopPropagation behavior, and the asynchronous access that no longer requires persist().


Quick Reference

Delegation and Events

ConceptReact 16React 17+
Listener locationdocumentRoot container
Native listeners per type11
Handler lookupFiber tree walkFiber tree walk
stopPropagation scopeReact dispatchReact dispatch

Pooling

AspectReact 16React 17+
Event poolingYesNo
Async access without persist()FailsWorks
e.persist()Required for asyncNo-op on web
React NativePooledPooled

SyntheticEvent Properties

PropertyPurpose
typeEvent type ('click')
targetElement that fired the event
currentTargetElement whose handler is running
nativeEventThe underlying browser event
preventDefault()Cancel default action
stopPropagation()Halt React dispatch to ancestors

Best Practices

✅ Do This:

// Access event properties asynchronously in React 17+
setTimeout(() => console.log(e.target.value), 100)                    // ✅
// Use e.stopPropagation() to halt React dispatch
e.stopPropagation()                                                   // ✅
// Use e.nativeEvent for integration with non-React libraries
thirdPartyLib.handle(e.nativeEvent)                                   // ✅

❌ Don’t Do This:

// Call e.persist() in React 17+ (it does nothing)
e.persist()                                                           // ❌
// Assume the event object is pooled
// It is not on the web since React 17                                   // ❌
// Use stopPropagation to stop native listeners above the root
// It only stops React's dispatch, not native bubbling past the root     // ❌

Common Pitfalls

PitfallWhy It HappensFix
e.target is null in async codeReact 16 poolingUpgrade to React 17+; no fix needed
persist is not a functionRemoved in React 17+Remove the call
stopPropagation does not stop native listenerDelegation is at rootUse native listener or check event phase
Handler fires for dynamically added elementDelegationWorks as expected
currentTarget is null in async codecurrentTarget is only valid during dispatchCapture the value synchronously

Real-World Examples

1. Delegation in a List

<tr onClick={() => handleRowClick(row.id)}>

2. Synthetic Event Access

console.log(e.type, e.target, e.nativeEvent)

3. Stop Propagation

e.stopPropagation()

4. Async Access

setTimeout(() => console.log(e.target.value), 100)

5. Native Event for Library

thirdPartyLib.handle(e.nativeEvent)

6. Prevent Default

e.preventDefault()

7. Current Target Capture

const target = e.currentTarget
setTimeout(() => console.log(target), 100)

8. Dynamic List Handler

{items.map(item => <li key={item.id} onClick={() => handle(item)}>)}

9. Document-Level Listener Escape

useEffect(() => {
  document.addEventListener('click', handler)
  return () => document.removeEventListener('click', handler)
}, [])

10. Pooling Check

// React 17+:
console.log(e.target) // available after handler returns

Visual

Event Delegation

┌──────────────────────────────────────────────────────────────┐
│  ROOT CONTAINER                                              │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Native click listener (ONE)                             ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │                                │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Fiber tree walk                                         ││
│  │                                                         ││
│  │ <table>                                                  ││
│  │   <tr onClick={...}>   ← handler found                  ││
│  │     <td>Click me</td>                                   ││
│  │   </tr>                                                  ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │                                │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ SyntheticEvent dispatched to the handler                ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  One native listener. N JSX handlers.                        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Pooling in React 16

┌──────────────────────────────────────────────────────────────┐
│  HANDLER RUNS                                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ e.target.value → 'hello'                                ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │ handler returns                │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ React resets the event object                           ││
│  │ e.target = null, e.target.value = null                  ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │ setTimeout fires later         │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ e.target.value → null                                   ││
│  │ (the object was recycled)                               ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  FIX in React 16: e.persist()                                │
│  FIX in React 17+: none needed                               │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Root Listener Location Change

┌──────────────────────────────────────────────────────────────┐
│  REACT 16                      REACT 17+                     │
│                                                              │
│  document                      root container                │
│  ┌─────────────────┐          ┌─────────────────┐           │
│  │ listener here   │          │ listener here   │           │
│  └────────┬────────┘          └────────┬────────┘           │
│           │                            │                     │
│           ▼                            ▼                     │
│  ┌─────────────────┐          ┌─────────────────┐           │
│  │ stopPropagation │          │ stopPropagation │           │
│  │ in non-React    │          │ in non-React    │           │
│  │ listener does   │          │ listener works  │           │
│  │ NOT stop React  │          │ as expected     │           │
│  └─────────────────┘          └─────────────────┘           │
│                                                              │
│  Moving the listener to the root fixed the conflict.         │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Event delegationOne listener per event type at the root
Listener location (React 17+)Root container, not document
SyntheticEventCross-browser wrapper around native event
nativeEventAccess the underlying browser event
Event poolingRemoved in React 17 on the web
e.persist()No-op on web in React 17+; still relevant in React Native
stopPropagation()Halts React’s dispatch to ancestor components
Dynamic elementsHandled automatically by delegation

Key takeaways:

  • React uses event delegation, not direct listeners. The handler you write in JSX is not attached to the element; it is registered in the Fiber tree, and a single root listener dispatches to it. The delegation model reduces memory usage and makes dynamically added elements work without re-attachment.
  • The root listener moved from document to the root container in React 17. The change fixed a conflict where a non-React library’s stopPropagation on document prevented React from receiving the event. The delegation model is the same; only the location of the listener changed.
  • The SyntheticEvent is a cross-browser wrapper, not the native event. It has the same interface, and the native event is available through nativeEvent. The wrapper normalizes browser differences so the handler does not need to branch on browser type.
  • Event pooling was removed in React 17. In React 16, the event object was recycled after the handler returned, and asynchronous access returned null. The e.persist() workaround was required. In React 17 and later, the event object is not reused, and asynchronous access works without any workaround.
  • stopPropagation stops React’s dispatch, not native bubbling. React collects the handlers along the Fiber path and invokes them in order. stopPropagation prevents the remaining handlers in that collected path from running. It does not prevent the native event from reaching the root container, because the native event has already reached the root container by the time React dispatches it.

Remember: React’s event system looks like the DOM but works differently underneath. The handler you write in JSX is not attached to the element; it is registered in the Fiber tree, and a single root listener dispatches to it. The SyntheticEvent wrapper normalizes browser differences, and the nativeEvent property gives you the real event when you need it. Pooling was a React 16 optimization that made asynchronous access fail; it was removed in React 17. If you see e.persist() in a tutorial, the tutorial is outdated for the web. The current model is simpler: the event object is yours to keep, and stopPropagation works the way the name suggests, within React’s dispatch tree.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!