This is a genuinely common race condition, and it's specifically the kind that only shows up under real usage conditions, someone clicking between items quickly, a slow network, exactly the situations casual development testing on a fast local connection rarely reproduces.
The Setup That Looks Completely Standard
'use client';
import { useState, useEffect } from 'react';
export function UserDetail({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]);
return <div>{user ? user.name : 'Loading...'}</div>;
}
This is close to the most common way to fetch data based on a changing prop in a Client Component, and it works completely correctly the vast majority of the time. It also has a real race condition that only shows up under a specific, genuinely common sequence of events.
The Exact Sequence That Triggers the Bug
A user clicks on user A in a list, userId becomes A's ID, the effect fires, a request for A's data starts. Before that request resolves, network latency, a slow connection, the user clicks user B instead, userId becomes B's ID, the effect fires again, a second, separate request for B's data starts. Now two requests are in flight simultaneously.
If B's request happens to resolve faster than A's, perfectly plausible depending on server load, caching, or just network variance, B's data renders correctly first. Then A's slower, now-stale request finally resolves, and its .then() callback runs setUser(data) with A's data, overwriting B's correct, current data with A's outdated, no-longer-relevant response. The component now displays user A's name and information while userId itself is actually set to B, a genuine, visible mismatch between what's shown and what's actually selected.
Why This Feels Random Rather Than Reliably Broken
This only manifests when the specific timing lines up, a slower first request resolving after a faster second one. On a fast, consistent local connection during development, requests often resolve close enough to their sending order that this exact interleaving rarely happens, which is exactly why it's easy to ship without ever seeing it locally, and exactly why it tends to surface first as a confusing, hard-to-reproduce bug report from a real user on a real, more variable connection.
The Actual Fix: Ignore Stale Responses Explicitly
'use client';
import { useState, useEffect } from 'react';
export function UserDetail({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) {
setUser(data);
}
});
return () => {
ignore = true; // marks this specific effect's request as stale on cleanup
};
}, [userId]);
return <div>{user ? user.name : 'Loading...'}</div>;
}
The ignore flag, scoped locally to each individual effect invocation, gets set to true in the cleanup function, which React runs automatically the moment userId changes again, before the next effect invocation starts. When A's slow request finally resolves, its specific closure's ignore flag is already true, since the effect was cleaned up the moment the user clicked B, and the stale setUser(data) call for A's outdated data simply never happens. Only B's request, whose ignore flag remains false because its effect was never cleaned up, actually updates state.
An Alternative Using AbortController
For fetch specifically, AbortController provides a more complete version of the same fix, actually canceling the in-flight request rather than just ignoring its eventual result:
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') {
console.error(err); // genuinely handle real errors, ignore expected aborts
}
});
return () => {
controller.abort();
};
}, [userId]);
This has a real advantage over the simple ignore flag, the actual network request gets canceled, not just its result discarded, which saves real bandwidth and server load for a request whose result was never going to be used anyway, particularly valuable for anything more expensive than a small JSON response.
Why This Matters More for Search and Filtering Specifically
This exact pattern shows up constantly in search-as-you-type and rapid filtering interactions, exactly the kind of interface covered in an earlier post on search and filtering patterns. A user typing quickly triggers a new request on every keystroke, and without this protection, a slower response to an earlier, now-outdated keystroke can overwrite the correct results for what the user is actually searching for right now, displaying results for a query they've already moved past.
The Actual Rule
Any effect that fetches data based on a value that can change again before the fetch resolves needs a way to distinguish a stale, no-longer-relevant response from a current one. A simple ignore flag set in the cleanup function handles this cheaply for most cases. AbortController handles it more completely, actually canceling the wasted request rather than just discarding its result, and is worth the small amount of extra code for anything with real cost behind each request, a genuinely expensive query, meaningful bandwidth, a rate-limited endpoint.
I handle this exact pattern, mostly with AbortController for anything backed by a real database query, across the dashboards and templates I build at pixelanas.com, since it's exactly the kind of subtle bug that looks fine in every casual test and only shows up once real users start clicking around at real speed.
If you've got a useEffect fetching data based on a changing prop with no stale-response handling, go test it specifically by clicking or navigating quickly between a few items in a row. If you see a flash of the wrong data settling in, that's this exact race condition. Drop what you find in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)