Why Async Code in React Sees Stale State
By Abdlazeez Olasunkanmi
Before async bugs in React make sense, you need a clear picture of what React is actually doing: when it calls your component, what “mount” and “unmount” mean, and when effects run. Everything else in this article is a consequence of that.
A render is a function call
A React component is a function, and a render is React calling that function. Here is the simplest component I can write:
function ThisIsMyComponent() {
const myCounts = 0;
return <div>{myCounts}</div>;
}Every time React calls ThisIsMyComponent, it creates a fresh, local myCounts. Call it twice and you get two separate variables. The myCounts from the first render is not the myCounts from the second render.
This is the most important fact in this article: state is not a variable that changes. It is a value React hands you, freshly, on every call.
When state goes from 0 to 1, React does not modify the old variable. It calls your function again, and a brand-new variable holds 1. That means any function you create during a render, such as an event handler, a timer callback, or an async function, stays tied to the variables of the render that created it.
Mounting, updating, unmounting
A component’s lifecycle is three situations:
- Mounting: React calls your component for the first time and puts the result into the DOM. An effect with an empty dependency array (
useEffect(..., [])) runs once, after the DOM is in place. - Updating: Something changed, so React calls your component again to update the DOM. Any effect whose dependencies changed runs again.
- Unmounting: React removes the component from the DOM and never calls that instance again. Cleanup functions returned from your effects run at this moment.
Here is a small example of cleanup on unmount:
import { useEffect } from "react";function ThisIsMyComponent() {
useEffect(() => {
// React saves this cleanup function and runs it
// when the component is removed from the DOM.
return () => {
console.log("UNMOUNTING: React is removing the component");
};
}, []); return <div style={{ background: "#ffcccc", padding: 10 }}>Appearing component</div>;
}A note on Strict Mode. In development, React’s Strict Mode deliberately runs effects as mount, cleanup, mount, to help you catch missing cleanups. If you try the demo above in a dev build, you will see the “UNMOUNTING” log immediately on load. Nothing is broken. In production it runs once, as described.
What happens to work you started before unmounting? Suppose you fire a fetch on mount and the component unmounts two seconds later. The request keeps running, and its .then still executes. Calling setState there is a harmless no-op in React 18 (the old warning was removed for that reason). The real problems are wasted work, and, as we will see, old responses overwriting newer data.
For a deeper dive, see Lifecycle of Reactive Effects in the React docs.
What useEffect actually does
useEffect runs after render, once the DOM has been updated. Its job is to synchronize your component with something outside React, such as a network request.
useEffect(() => {
// runs after render return () => {
// cleanup: runs before the next run of this effect,
// and again on unmount
};
}, [dependencies]);Closures: every render takes a snapshot
A closure is a function plus the variables it captured when it was created. Every function in JavaScript is a closure. In React this matters because your functions capture the render they were created in.
function Counter() {
const [count, setCount] = useState(0); function handleClick() {
setCount(count + 1); // uses this render's `count`
} return <button onClick={handleClick}>+1</button>;
}In render 1, count is 0, and handleClick is created right there. It takes a snapshot of its surroundings: "I was born in a world where count is 0, and I will remember 0 forever." When you click, you call the handler from the current render, so the snapshot is current. This works because the code is synchronous.
Now add a delay:
function handleClick() {
setTimeout(() => {
setCount(count + 1);
}, 3000);
}Say you click the button three times quickly, within the 3 seconds. Nothing has re-rendered yet, because re-rendering requires setCount to run, and that is still waiting inside the timers. So all three clicks happen in render 1, where count is 0. You now have three timers, each holding the same snapshot.
Get Abdlazeezolasunkanmi’s stories in your inbox
Join Medium for free to get updates from this writer.
When they fire, each one computes 0 + 1 and calls setCount(1). The final count is 1, not 3. The function was created in an old render, and it executed long after that render had passed.
What await does to all of this
async function handleClick() {
console.log("Start");
await sleep(1000); // the break point
setCount(count + 1); // the continuation
}When you call handleClick():
- It runs synchronously up to the
await: it logs "Start" and kicks off the timer. - It pauses. The function returns control at that exact point. Its execution is suspended mid-flight.
- The browser keeps moving. The main thread is free, so clicks can be handled and React can render. Nothing is frozen.
- It resumes. After one second, the rest of the function is put back on the stack and continues exactly where it left off.
The key point: when it wakes up at step 4, it is still inside the same closure it started in. It does not re-read the component function, and it does not look at what the screen currently shows. If count was 0 when the function was split in half, the second half uses 0, unaware of anything that changed while it waited.
The effect race: where ignore really matters
These closure rules cause a classic bug when fetching data in an effect. Imagine a search box where query changes as the user types:
useEffect(() => {
fetchResults(query).then(setResults);
}, [query]);The user types “a”, then quickly “ab”. Two requests are in flight. If the request for “a” is slow and finishes after the one for “ab”, the screen ends up showing results for the wrong query.
The standard fix is an ignore flag, declared inside the effect:
useEffect(() => {
let ignore = false; // belongs only to this run of the effect fetchResults(query).then((data) => {
if (!ignore) setResults(data);
}); return () => {
ignore = true; // marks only this run as stale
};
}, [query]);Each run of the effect creates its own ignore. When query changes, React runs the previous effect's cleanup, which flips that run's flag to true. The new run starts with its own fresh false, so only the stale response gets discarded.
The version that breaks
The bug appears when the flag lives somewhere that is genuinely shared across renders, like module scope or a ref:
let ignore = false; // module scope: ONE variable for every renderfunction SearchResults({ query }) {
useEffect(() => {
fetchResults(query).then((data) => {
if (!ignore) setResults(data);
}); return () => {
ignore = true; // never reset to false
};
}, [query]);
}The sequence is: effect for “a” starts, query changes, cleanup runs and sets ignore = true, then the effect for "ab" starts. But there is only one ignore, and it is now true. Both responses are discarded, and the screen never updates.
(Notice that declaring let ignore in the component body would actually work, because the body re-runs on every render and creates a separate variable each time. Per-render variables are exactly what this article is about. The shared variable is the dangerous one.)
How to fix these bugs
1. Use the functional updater when new state depends on old state.
setTimeout(() => {
setCount((c) => c + 1); // React supplies the latest value
}, 3000);Three clicks now give 3, because each update receives the latest state instead of a stale snapshot.
2. Use a per-effect ignore flag (as shown above) to discard stale responses.
3. Cancel the request with AbortController, which also saves the wasted work:
useEffect(() => {
const controller = new AbortController(); fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then(setResults)
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
}); return () => controller.abort();
}, [query]);4. Use a ref when you genuinely need the latest value inside an async callback. A ref is one object that persists across renders, so reading ref.current later gives the current value, not a snapshot.
Conclusion
Every bug in this article comes from one fact: a render is a function call, and every function created in it remembers that call’s variables. Async code is simply code that runs later, so it can wake up in a world that has already moved on.
When you review async React code, ask three questions:
- Which render’s variables is this function holding?
- Is anything shared across renders that should belong to a single effect run?
- What happens if this finishes after the component has updated or unmounted?
If you want more, Dan Abramov’s A Complete Guide to useEffect and the react.dev docs on effects go further on these ideas.






