Lesson 40 of 80
Fetching in useEffect is the obvious default — and it has a race condition baked in that most implementations get wrong. That gap is exactly why cache-based fetching libraries exist.
16 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
The obvious way to fetch data in a component is: useEffect runs, kicks off a fetch, and setState when it resolves. That works — until the thing you're fetching by changes quickly, like a userId prop flipping from 1 to 2 before the first request has come back. Now you have two in-flight requests, and there's no guarantee they resolve in the order they were sent. If the request for userId=1 resolves after the request for userId=2, you silently overwrite fresh data with stale data.
This is why "just fetch in an effect" is the pattern you learn first and the pattern real apps mostly move away from. Cache-based libraries (React Query, SWR, and RSC's built-in fetch deduping) exist specifically to close this gap, plus a handful of related ones a raw effect doesn't handle at all.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true; // runs before the next effect, and on unmount
};
}, [userId]);
return user ? <Profile user={user} /> : <Spinner />;
}
If userId changes from 1 to 2, the effect's cleanup runs first and sets ignore = true for the old closure — the one still waiting on the request for userId=1. When that request finally resolves, ignore is true inside that specific closure, so setUser is skipped. Only the request that matches the current userId is allowed to update state.
Miss the ignore guard entirely and this becomes a genuine bug: switch to a user, switch away, and if the first request is slow enough, its response can land after the second one and clobber the profile you're actually looking at.
A cache-based library does this same guard for you automatically, and adds what a hand-rolled effect doesn't: caching per query key (switching back to userId=1 is instant if it's still cached), request deduping (two components fetching the same userId share one request), and background revalidation instead of a manual refetch button.
.catch() and an error state too, which is one more piece cache libraries give you as a built-in convention instead of a per-component reinvention.The ignore guard only works because of the cleanup-function timing covered in the earlier lesson on side effects — cleanup runs before the next effect fires, which is precisely what lets the previous, now-outdated request recognize itself as outdated when it finally resolves.