Your useEffect Fetches the Wrong Data and You Don't Even Know It
2/27/2026
You have a component that fetches user data based on a prop. It works in dev. It works in staging. Then in production, a user switches tabs fast enough and the UI shows someone else's profile.
Here's the code you probably wrote:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, []);
return <div>{user?.name}</div>;
}
That empty dependency array is a lie you told React. You said "run this once." What you meant was "run this every time userId changes." But you didn't write that, so React trusts you, fetches the first userId it sees, and never updates again.
The component re-renders when userId changes — React does its job. But your effect doesn't re-run. setUser never fires with the new data. The UI is stuck showing the first user forever. No error in the console. No crash. Just wrong data served with full confidence.
I lost a client in 2019 over something like this. A dashboard that showed stale analytics after tab switches. I didn't understand useEffect well enough to know what I was looking at. The data was "there" — just not the right data. That kind of bug is the worst kind: the app looks like it works.
The stale closure trap
Let's make it worse. Say you add a filter:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [filter, setFilter] = useState('basic');
useEffect(() => {
fetch(`/api/users/${userId}?detail=${filter}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
You fixed the first bug — userId is in the dependency array now. But filter isn't. This is a stale closure. The effect captures filter at the time it was created. When filter changes from 'basic' to 'full', your effect doesn't re-run. The fetch still sends ?detail=basic even though the user selected the full view.
Concrètement, here's the timeline:
- Component mounts,
filter = 'basic', effect runs, fetches/api/users/42?detail=basic✓ - User changes filter to
'full', component re-renders - Effect does NOT re-run because
userIddidn't change - UI re-renders with the same stale data
- User sees "full detail" selected but basic data displayed
No error. No warning in production. React's linter would catch this in your editor — but only if you listen to it. I've seen teams slap // eslint-disable-next-line react-hooks/exhaustive-deps on their effects like it's a magic fix. It's not a fix. It's a mute button on a fire alarm.
Why the linter isn't always enough
The linter catches missing primitives and stable references. It can't always reason about objects and functions. Look at this:
function Dashboard({ config }) {
const [data, setData] = useState(null);
const fetchOptions = {
headers: { 'X-Config': config.apiKey },
signal: new AbortController().signal,
};
useEffect(() => {
fetch('/api/dashboard', fetchOptions)
.then(res => res.json())
.then(setData);
}, [fetchOptions]);
return <div>{data?.summary}</div>;
}
This looks correct. fetchOptions is in the dependency array. The linter is happy. But here's the problem: fetchOptions is a new object on every single render. {} !== {} in JavaScript. React compares dependencies with Object.is, which means reference equality for objects. Your effect runs on every render. Every keystroke, every state change anywhere in the tree — new fetch. You just built an accidental DDoS against your own API.
I've seen this pattern tank a staging environment. The dev was confused because "I followed the linter." The linter doesn't know your object is recreated every render. It just checks that what you reference inside the effect is listed in the array.
The fixed version
Here's the same component, done right:
function Dashboard({ config }) {
const [data, setData] = useState(null);
const apiKey = config.apiKey;
useEffect(() => {
const controller = new AbortController();
fetch('/api/dashboard', {
headers: { 'X-Config': apiKey },
signal: controller.signal,
})
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, [apiKey]);
return <div>{data?.summary}</div>;
}
What changed:
- Extract the primitive (
apiKey) instead of depending on the whole object. Strings are compared by value, not reference. The effect only re-runs whenapiKeyactually changes. AbortControllerin the effect, not outside. Each effect invocation gets its own controller.- Cleanup function aborts the previous request. This is not optional — it's the entire point.
If you need to depend on a computed value that's an object, wrap it in useMemo:
const queryParams = useMemo(
() => ({ apiKey: config.apiKey, region: config.region }),
[config.apiKey, config.region]
);
useEffect(() => {
// fetch using queryParams
}, [queryParams]);
Now queryParams only gets a new reference when its inputs actually change. The effect stays stable.
For callback dependencies, same idea with useCallback:
const handleFetch = useCallback(async (signal) => {
const res = await fetch(`/api/users/${userId}`, { signal });
return res.json();
}, [userId]);
useEffect(() => {
const controller = new AbortController();
handleFetch(controller.signal).then(setUser);
return () => controller.abort();
}, [handleFetch]);
handleFetch gets a new reference only when userId changes. The effect follows.
The edge case nobody talks about: mid-fetch dependency changes
This is the one that keeps showing up in production. Your user clicks a profile, the fetch starts, then they click another profile before the first fetch resolves. Both fetches are in flight. Which one wins?
Without cleanup, the last one to resolve wins. That might be the first fetch — network responses don't arrive in order. User clicks profile 42, then profile 99. Fetch for 99 resolves first (fast cache hit). Then fetch for 42 resolves (slow cold query). setUser fires with user 42's data. The UI shows user 42 while the URL says user 99.
This is a race condition. In practice, it happens more than you think — especially on slow 3G connections or when your API has variable response times. I debug production issues for clients, and this one shows up at least twice a year. The worst part: it's intermittent. You can't reproduce it on your fast dev machine with a local API.
Here's the pattern that kills it:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setUser(null);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`Status ${res.status}`);
return res.json();
})
.then(data => {
if (!cancelled) setUser(data);
})
.catch(err => {
if (!cancelled && err.name !== 'AbortError') {
setError(err.message);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [userId]);
if (error) return <div>Failed: {error}</div>;
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
Two layers of protection:
AbortControllercancels the network request. The browser stops waiting for bytes. This matters for large payloads — you don't want abandoned fetches eating bandwidth.cancelledflag prevents state updates even if the response somehow arrives after abort (some browsers have edge cases here). Belt and suspenders.
The cleanup function runs before the next effect. So when userId changes from 42 to 99:
- Cleanup runs:
cancelled = true,controller.abort()for the 42 fetch - New effect runs: fresh
cancelled = false, new controller, fetch for 99 - If 42's response arrives late,
cancelledistrue→setUsernever fires - 99's response arrives →
setUser(data)runs → correct data displayed
Reset state on dependency change too. That setUser(null) at the top of the effect clears stale data immediately. Without it, the user sees the old profile for the entire duration of the new fetch. That's not a loading state — that's a lying state.
The checklist
Before you ship a useEffect that fetches data:
- Every variable from the component scope that you use inside the effect is in the dependency array. No exceptions. No eslint-disable.
- Objects and functions are either extracted to primitives, wrapped in
useMemo/useCallback, or moved inside the effect. - There's a cleanup function that aborts in-flight requests.
- There's a
cancelledflag that guardssetStatecalls. - State resets at the top of the effect so stale data doesn't linger.
I keep a private list of every dumb question I've asked and every bug I've shipped. The stale closure bug is on there three times — 2019, 2021, and once in 2023 when I thought I was past it. React hooks don't care how many years you've been coding. Skip a dependency, skip the cleanup, and the bug ships. The code doesn't negotiate.