Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Synthetic event becomes stale after async operation

I'm experiencing an issue with a TextField where e.target.value becomes stale after an async operation(FileReader API) in the onChange handler.

When I capture e.target.value in a variable before the async operation and use that variable to update state, everything works fine. However, if I try to access e.target.value after the async operation completes, it always contains the old value instead of the current input value.

Why does e.target.value lose its current value after an async operation, and why does storing it in a variable beforehand solve the problem?

React version 19.0.0

import React from 'react';

export default function BasicCard() {

  const [a, setA] = React.useState("place");
  
  return <input
        value={a} 
        onChange={async (e) => {
          const value = e.target.value; // Storing in variable - WORKS
          await new Promise((resolve) => { resolve(true) }); // FileReader API
          
          console.log(2, e.target.value); // This logs the OLD value, not current
          
          // setA(e.target.value); // This DOESN'T work - uses stale value
          setA(value); // This WORKS - uses captured value
        }}
      />
}


export function App(props) {
  return (
    <div className='App'>
      <BasicCard />
    </div>
  )
}

Expected: e.target.value should contain the current input value even after the async operation.

Actual: e.target.value reverts to the previous value after the async operation completes, causing the state update to fail.

Workaround: Capturing e.target.value in a const before the async operation works correctly.

Edit: Steps to reproduce:

paste the above code in https://playcode.io/mui or minimal reproducible example here https://stackblitz.com/edit/vitejs-vite-fbcc9afp?file=src%2FApp.tsx

PS: deferred input can be used for better UX, there are definitely better ways to handle this, but I am just curious why is this happening

like image 804
Jayesh Vyavahare Avatar asked Oct 30 '25 03:10

Jayesh Vyavahare


2 Answers

e.target.value becomes stale after an async operation because React reuses its synthetic event objects, and the underlying DOM input’s value may have changed since the event was first fired.

When you store e.target.value in a local variable before the async operation, you “snapshot” the current value while the event and its target are still fresh.

From the docs (uses a very similar example):

The SyntheticEvent objects are pooled. This means that the SyntheticEvent object will be reused and all properties will be nullified after the event handler has been called. For example, this won’t work:

function handleChange(e) {
  // This won't work because the event object gets reused.
  setTimeout(() => {
    console.log(e.target.value); // Too late!
  }, 100);
}

The docs say this is not true for Reactv17+, which is misleading. For React 17+, e.persist is now a noop & React stopped physically pooling events, BUT the lifecycle semantics remain the same.

That means the real "fix" would be to store the value prior to avoid referencing a possibly outdated DOM element value.

like image 61
Dom Avatar answered Oct 31 '25 17:10

Dom


It's because React tries to keep the DOM up to date with your state.

In your code you have:

export default function BasicCard() {

  const [a, setA] = React.useState("place");  
  return <input value={a} 
        onChange={async (e) => {
          await new Promise((resolve) => { resolve(true) });
          setA(e.target.value);
        }}
      />
}

You can see how value={a} and also how your change event is async. What appears to happen internally when any keypress happens in the input is:

  1. The value of the underlying DOM element changes based on what you typed
  2. The change event is triggered but because there is an async operation it get suspended when await is encountered without changing the value of a
  3. React (somehow) determines that the DOM is out of sync with your component and triggers a re-render on BasicCard
  4. Your input is re-rendered with a as its value
  5. At some future point your async operation finishes awaiting and continues execution where it calls setA with e.target.value which is the value after the re-render

The observation regarding event pooling is generally speaking a valid one, however it doesn't actually affect this particular case.

like image 29
apokryfos Avatar answered Oct 31 '25 17:10

apokryfos



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!