Why Your Async Callbacks See Old React Values

JavaScript closures are a common source of confusion in React. When you combine them with asynchronous code, you can end up with callbacks that reference stale props or state — values that were current when the callback was created, but are no longer the latest values when the callback actually runs.

The canonical example comes straight from the React docs. A Counter component increments a click count, and a button triggers an alert after a three-second delay to show that count:

function Counter() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

If you click “Show alert” and then quickly click “Click me” before the alert fires, the alert shows a count that doesn’t match what’s on screen. The number in the alert isn’t random — it’s the value of count at the moment the callback inside setTimeout was created. This is just how closures work: the asynchronous function captures the variable reference from its lexical scope at definition time.

When state updates, React doesn’t just change a value — it creates an entirely new variable reference in memory. Even if the state is a non-primitive object, the reference your async callback holds is not the same object in memory as the current one.

The Direct Fix: Mirror State in a Ref

React’s own guidance for this situation is to keep the latest state in a ref, mutate it, and read from that instead. The key insight is in useRef’s contract:

[...] useRef will give you the same ref object on every render.

Because React hands back the identical object in memory on every render, any callback — regardless of when it’s defined or executed — is working with the same reference. No staleness.

The straightforward implementation creates a ref initialized with count, manually updates ref.current every place the state changes, and reads ref.current in the async code:

function Counter() {
  const [count, setCount] = useState(0);
  const ref = useRef(count); // Make a ref and give it the count

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + ref.current); // Use ref instead of count
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button
        onClick={() => {
          setCount(count + 1);
          ref.current = count + 1; // Update ref whenever the count changes
        }}
      >
        Click me
      </button>
      <button
        onClick={() => {
          handleAlertClick();
        }}
      >
        Show alert
      </button>
    </div>
  );
}

This works, but it’s fragile. If you update state in many places, you have to remember to sync the ref everywhere.

A Cleaner Approach: Route Updates Through One Function

Instead of sprinkling ref-sync instructions through the code, wrap the state update in a single function. Create an updateState function that takes the new state, sets ref.current to it, and also calls the real state setter. Replace all direct calls to setCount with updateState:

function Counter() {
  const [count, setCount] = useState(0);
  const ref = useRef(count);

  // Keeps the state and ref equal
  function updateState(newState) {
    ref.current = newState;
    setCount(newState);
  }

  function handleAlertClick() { ... }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button
        onClick={() => {
          // Use the created function instead of the manual update
          updateState(count + 1);
        }}
      >
        Click me
      </button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

Now when you change state, the ref stays synchronized automatically — as long as you remember to use updateState to trigger changes.

Extracting a Custom Hook: useAsyncReference

To make this reusable across components and states, move the logic into a custom hook. Outside the component, define a function named useAsyncReference (following the convention of prefixing hooks with “use”). The hook takes a value parameter and stores it only in a ref, avoiding duplicated state. It returns an array of the ref and an updateState function that sets ref.current:

function useAsyncReference(value) {
  const ref = useRef(value);

  function updateState(newState) {
    ref.current = newState;
  }

  return [ref, updateState];
}

function Counter() { ... }

There’s a problem: updating a ref doesn’t trigger a re-render. Without a state change, the screen won’t reflect the new value. The workaround is a “fake” state whose only job is to provoke a re-render. Toggle a boolean via forceRender whenever the ref updates:

function useAsyncReference(value) {
  const ref = useRef(value);
  const [, forceRender] = useState(false);

  function updateState(newState) {
    ref.current = newState;
    forceRender(s => !s);
  }

  return [ref, updateState];
}

function Counter() { ... }

The component can then consume the hook. Replace useState and the manual ref handling with useAsyncReference. The first value returned is a ref, so all reads become count.current. The second value updates the state/ref:

function useAsyncReference(value) { ... }

function Counter() {
  const [count, setCount] = useAsyncReference(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + count.current);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count.current} times</p>
      <button
        onClick={() => {
          setCount(count.current + 1);
        }}
      >
        Click me
      </button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

Extending the Hook to Props

This hook handles stale state, but stale props present a related challenge. Imagine moving the alert logic into a separate Alert component that receives count as a prop:

function useAsyncReference(value) { ... }

function Alert({ count }) {
  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }

  return <button onClick={handleAlertClick}>Show alert</button>;
}

function Counter() { ... }

Pass the current ref value into the component:

function useAsyncReference(value) { ... }

function Alert({ count }) { ... }

function Counter() {
  const [count, setCount] = useAsyncReference(0);

  return (
    <div>
      <p>You clicked {count.current} times</p>
      <button
        onClick={() => {
          setCount(count.current + 1);
        }}
      >
        Click me
      </button>
      <Alert count={count.current} />
    </div>
  );
}

The ref inside Counter is safe, but the count prop in Alert is captured stale by setTimeout. The trick is to reuse the same hook for props.

Add a second parameter, isProp, defaulting to false. If isProp is true, sync ref.current with the incoming value during render, and return only the ref — there’s no update function needed for props:

function useAsyncReference(value, isProp = false) {
  const ref = useRef(value);
  const [, forceRender] = useState(false);

  function updateState(newState) {
    ref.current = newState;
    forceRender(s => !s);
  }

  if (isProp) {
    ref.current = value;
    return ref;
  }

  return [ref, updateState];
}

function Alert({ count }) { ... }

function Counter() { ... }

Update the Alert component to use the hook, passing true as the second argument since count is a prop:

function useAsyncReference(value) { ... }

function Alert({ count }) {
  const asyncCount = useAsyncReference(count, true);

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + asyncCount.current);
    }, 3000);
  }

  return <button onClick={handleAlertClick}>Show alert</button>;
}

function Counter() { ... }

Now the same hook handles both state and prop staleness.

Matching React’s Bailout Behavior

One last refinement. React’s useState will skip a re-render if the new state is identical to the previous one (per its docs). The custom hook’s updateState currently forces a re-render regardless of whether the value changed. To match React’s behavior, wrap the logic in a comparison using Object.is(), exactly as React does internally:

function useAsyncReference(value, isProp = false) {
  const ref = useRef(value);
  const [, forceRender] = useState(false);

  function updateState(newState) {
    if (!Object.is(ref.current, newState)) {
      ref.current = newState;
      forceRender(s => !s);
    }
  }

  if (isProp) {
    ref.current = value;
    return ref;
  }

  return [ref, updateState];
}

function Alert({ count }) { ... }

function Counter() { ... }

With that, useAsyncReference behaves like useState — with the added safety that async callbacks always see the current value, whether your data arrives as state or as props.