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:
[...]
useRefwill give you the samerefobject 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.



