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.