skip to content

Older React guidance says you must call e.persist() before using an event inside setTimeout or after an await. What was event pooling, and what is actually true from React 17 onward?

level: middleimportance: should knowfreq 45%

answer

  1. a React 16 optimisation, now deleted
  2. objects were recycled after the handler
  3. React 17 changed the answer
  4. persist() is a fossil
  5. currentTarget is still a DOM rule

basics

~20 s

Before React 17, React reused one SyntheticEvent object per event type and nulled its fields once the handler returned, so async reads saw null unless you called e.persist(). React 17 removed pooling; in React 19 the event is an ordinary object and persist() does nothing.

solid answer

~40 s

Pooling was a React 16-era optimisation: React kept a pool of SyntheticEvent objects, and as soon as your handler returned it wiped every property back to null and put the object back for reuse. Anything asynchronous — a `setTimeout`, a promise continuation, storing the event in state — read nulls, and in development React logged a warning telling you to call `e.persist()`, which pulled that object out of the pool. React 17 deleted pooling outright, because it did not measurably help in modern engines and confused far more people than it sped up. React 19 keeps that: the event is a normal object with a normal lifetime, so capture it freely; `e.persist()` still exists as a no-op for old code. One trap survives — `e.currentTarget` is reset when dispatch ends, so read it synchronously.

code

javascript · 9 lines
javascript
function SaveButton({ onSave }) {
  async function handleClick(e) {
    const label = e.currentTarget.textContent; // read now: reset once dispatch ends
    await onSave();
    console.log(e.type, e.target, label);      // fine in React 17+, no persist()
  }

  return <button onClick={handleClick}>Save</button>;
}

go deeper

for a junior

Know that the persist() advice is outdated: since React 17 you can use the event after an await or inside setTimeout without any extra call.

for a middle

Explain the mechanism — a per-type pool, properties nulled on release, the dev warning — and say React 17 removed it because the optimisation did not pay off on modern engines.

for a senior

Separate the surviving trap from the dead one: currentTarget being null after dispatch is DOM semantics that no React version changes, so show the read-synchronously-then-await pattern.

for a principal

Treat it as a case study in optimisations that outlive their justification: a micro-optimisation leaked into the public API contract, cost years of developer confusion, and its removal was shippable only because the wrapper hid the implementation.

## What pooling actually did Up to React 16, React maintained a pool of SyntheticEvent instances per event type. Dispatching a click took an object from the pool, filled in `target`, `clientX`, and the rest, ran your handlers, and then called an internal release step that set every property back to `null` and returned the object to the pool for the next click. The consequence was that a synthetic event was only valid *during* the synchronous run of your handler: ```js // React 16 behaviour function handleClick(e) { setTimeout(() => { console.log(e.type); // null — the object was recycled }, 0); } ``` In development, React detected reads on a released event and warned: the event is reused for performance reasons and all properties have been nullified. The documented workaround was `e.persist()`, which removed that instance from the pool so it kept its data. This is why so many mid-2010s answers, Stack Overflow posts and course videos still repeat "remember to persist the event." ## Why it was removed React 17 removed pooling entirely. The stated reasoning was that the optimisation did not improve performance on modern browsers — object allocation and GC for a handful of short-lived event objects is cheap — while it created a steady stream of confusing bugs for everyone who touched an event asynchronously. React 19 inherits that removal; nothing has brought pooling back. So in current React: ```js async function handleClick(e) { await save(); console.log(e.type, e.target); // works, no persist() needed } ``` You can also put the event object in state, close over it, pass it to a reducer, or keep it in a ref. It is an ordinary JavaScript object with the ordinary lifetime any object has. ## What happened to persist() `e.persist()` was kept as a callable no-op so that older code and libraries do not crash. Calling it is harmless and pointless. If you are reading code that calls it, that is a fossil, not a requirement. ## The trap that survives: currentTarget Here is the nuance that separates a rehearsed answer from an understood one. Even with pooling gone, this still logs `null`: ```js async function handleClick(e) { await save(); console.log(e.currentTarget); // null } ``` That is **not** React. `currentTarget` is defined by the DOM to be meaningful only while the event is being dispatched — it names the node whose listener is currently running, so once dispatch finishes there is no such node and the browser resets it. The same is true of a plain `addEventListener` handler with no React involved. The fix is to read what you need synchronously and keep the value: ```js async function handleClick(e) { const form = e.currentTarget; // read now const data = new FormData(form); // or extract now await save(data); } ``` `e.target` behaves differently: it points at the node where the interaction originated, and that reference stays valid after dispatch — though the node may since have been unmounted, so treat it as possibly detached. ## How to answer this in an interview The question is really a dating check: it tells the interviewer whether your React knowledge is current or memorised from an old course. The strong answer has three beats — describe what pooling was and why the warning existed, state clearly that React 17 removed it and persist() is now a no-op, and add the currentTarget caveat to show you know which part of the old advice was React's and which part is the DOM's. ## A related habit worth dropping The pooling era also produced defensive code like `const { value } = e.target` at the top of every handler "in case the event goes away." Destructuring the one value you need is still fine style, but do not present it as a necessity — in React 19 it is a preference, not a workaround.

  • If pooling is gone, why does reading e.currentTarget after an await still give null?
    Because that is a DOM rule, not a React one. `currentTarget` identifies the node whose listener is executing, so the browser resets it once dispatch finishes — a plain addEventListener handler behaves identically. Read it synchronously and store the node, or extract the data you need (for example `new FormData(e.currentTarget)`) before awaiting.
  • Is it still worth destructuring e.target.value at the top of a change handler?
    As style, yes — it is concise and makes the handler's dependency obvious. As a *correctness* measure, no: since React 17 the event object survives, so nothing forces you to copy the value out first. Treat it as a preference. The one real reason to copy early is currentTarget, which genuinely does not survive dispatch.
  • What did calling e.persist() do, and what does it do now?
    In React 16 it removed that synthetic event from React's reuse pool so its properties were not nulled when the handler returned — the sanctioned way to use an event asynchronously. Since React 17 removed pooling, the method remains callable purely so legacy code does not throw, and it has no effect whatsoever.

saying these in an interview costs you the question

  • Says you still need e.persist() for async handlers
  • Claims pooling was removed because it leaked memory
  • Blames React pooling for a null currentTarget
  • Thinks e.target is nulled after the handler returns
  • Says storing an event in state is forbidden in React

context