When to Avoid React Event Pooling

This article explains when and why you should avoid event pooling in React, focusing on the challenges of accessing event properties asynchronously. It covers how React’s legacy event system reused event objects, the common bugs this caused in asynchronous code, how to bypass it using e.persist(), and how React 17 ultimately eliminated event pooling altogether.

Understanding React Event Pooling

In React 16 and earlier, React used a technique called event pooling to improve performance. Instead of creating a new event object for every single user interaction, React created a single pool of SyntheticEvent objects.

When an event was triggered, React would grab an event object from the pool, populate its properties (like target and type), run your event handler, and then immediately clear (nullify) all properties so the object could be reused for the next event.

When to Avoid Event Pooling (React 16 and Earlier)

You must avoid relying on the default event pooling behavior whenever you need to access event properties inside an asynchronous callback.

Because React nullifies the event object immediately after the synchronous event handler finishes executing, any asynchronous code will attempt to read properties from a cleared event object. This results in console errors or null/undefined values.

Specifically, you must bypass event pooling in the following scenarios:

1. Inside setTimeout or setInterval

If you attempt to read an event property inside a timer delay, the pool will have already reclaimed the event object.

// This will fail in React 16 and below
function handleChange(e) {
  setTimeout(() => {
    console.log(e.target.value); // Error: Cannot read property 'value' of null
  }, 1000);
}

2. Inside Promises and Async/Await Blocks

If you fetch data or resolve a promise and then try to use the event object, the properties will be gone.

// This will fail in React 16 and below
function handleClick(e) {
  fetch('/api/log').then(() => {
    console.log(e.target.id); // e.target is null
  });
}

3. Inside State Updater Functions

React state updates can be batched and executed asynchronously. If you try to pass the event directly into a functional state updater, the event properties will be cleared before the state update runs.

// This will fail in React 16 and below
function handleInputChange(e) {
  setUserData(prev => ({
    ...prev,
    name: e.target.value // e.target is null
  }));
}

How to Avoid the Pitfalls of Event Pooling

If you are working on a legacy React codebase (React 16 or older), you have two options to prevent event pooling from breaking your code.

Solution 1: Use e.persist()

Calling event.persist() on the synthetic event removes it from the pool. This allows the event object to retain its values, making it safe to use in asynchronous callbacks.

function handleChange(e) {
  e.persist(); // Tells React not to release this event back to the pool
  
  setTimeout(() => {
    console.log(e.target.value); // Works perfectly
  }, 1000);
}

Solution 2: Cache the Value Synchronously

Instead of persisting the entire event object, extract and store the specific properties you need in a local variable before entering the asynchronous context. This is often the cleanest and most performant solution.

function handleChange(e) {
  const inputValue = e.target.value; // Cache the value synchronously
  
  setTimeout(() => {
    console.log(inputValue); // Works perfectly without e.persist()
  }, 1000);
}

Modern React: Why Event Pooling is No Longer an Issue

If you are using React 17 or newer, you do not need to worry about event pooling.

The React team realized that the performance benefits of event pooling on modern browsers were negligible, while the confusion and bugs it caused for developers were significant. Consequently, event pooling was completely removed in React 17.

In modern React apps, e.persist() is still available but does absolutely nothing, and event objects can be accessed asynchronously without any extra code.