Your state updates are lying to you.
Race conditions are not a backend problem. They happen in every React app that fetches data, debounces input, or runs anything asynchronous — which is every React app.
The tricky part is that they rarely crash. They just show the wrong data. A search result from a previous query. A form submitted with a stale value. A dashboard that briefly flickers between two states. Users notice. Developers do not, because the bug disappears on the next render.
The classic: out-of-order responses
This is the most common race condition in React:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(\`/api/users/\${userId}\`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]);
return <div>{user?.name}</div>;
}
Click user A. Request goes out. Click user B quickly. Second request goes out. The second request finishes first — you see user B. Then the first request finishes — now you see user A. You are looking at user A's data while the URL says user B.
This happens because setUser does not know which request it belongs to. It just sets whatever arrives last.
Fix 1: AbortController
The simplest fix. Cancel the previous request when a new one starts:
useEffect(() => {
const controller = new AbortController();
fetch(\`/api/users/\${userId}\`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setUser(data))
.catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, [userId]);
When userId changes, the cleanup function aborts the in-flight request. The stale response never reaches setUser. This is the pattern you should use by default for any fetch inside useEffect.
Fix 2: ignore flag
Sometimes you cannot abort the request — maybe it is a third-party SDK call or a WebSocket message. Use a boolean flag instead:
useEffect(() => {
let ignore = false;
fetchUser(userId).then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);
The stale response still arrives, but you ignore it. React's own documentation recommends this exact pattern. It works, but AbortController is better when available because it also saves bandwidth.
Stale closures in event handlers
Race conditions are not limited to fetching. They also appear in event handlers that reference stale state:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(count + 1); // captures count at the time of click
}, 1000);
};
return <button onClick={handleClick}>{count}</button>;
}
Click three times quickly. You expect 3. You get 1. Every click captured count as 0, so every timeout sets it to 1.
The fix is the functional updater:
setCount((prev) => prev + 1);
This reads the current state at the time of the update, not at the time the closure was created. Use functional updates whenever your next state depends on the previous state. Always.
Optimistic updates gone wrong
Optimistic UI is powerful but creates a window where local state and server state diverge. If you do not handle the failure path, the user sees data that was never persisted:
const handleLike = async () => {
setLiked(true); // optimistic
setLikes((n) => n + 1); // optimistic
try {
await api.likePost(postId);
} catch {
setLiked(false); // rollback
setLikes((n) => n - 1); // rollback
}
};
This looks correct until the user clicks like, unlikes, and likes again before the first request finishes. Now you have three in-flight mutations and your rollback logic is racing against itself.
The fix: track the request, not just the state:
const handleLike = async () => {
const nextLiked = !liked;
const delta = nextLiked ? 1 : -1;
setLiked(nextLiked);
setLikes((n) => n + delta);
try {
await api.setLikeStatus(postId, nextLiked);
} catch {
setLiked(!nextLiked);
setLikes((n) => n - delta);
}
};
Or better — use a library like TanStack Query that manages optimistic updates with proper rollback and request deduplication built in. Do not reinvent this.
Debounced search with stale results
Another common pattern that hides a race condition:
const handleSearch = useMemo(
() =>
debounce(async (query: string) => {
const results = await api.search(query);
setResults(results);
}, 300),
[]
);
The debounce prevents excessive requests, but it does not prevent out-of-order responses. If "rea" returns slower than "reac", you type "react", see results for "reac", then results for "rea" overwrite them.
Combine debounce with AbortController:
const controllerRef = useRef<AbortController | null>(null);
const handleSearch = useMemo(
() =>
debounce(async (query: string) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
try {
const results = await api.search(query, {
signal: controller.signal,
});
setResults(results);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
throw err;
}
}, 300),
[]
);
The general principle
Every race condition in React state follows the same pattern: an async operation completes and writes to state that has moved on since the operation started.
The defenses are always one of:
Most of these are not complex. They are just easy to forget. The hardest part is knowing where to look — and now you know.