Explain the use case of `useEffect()` for fetching data from an API.
useEffect runs after render, making it the classic place to fetch data on mount or when dependencies change. You manage loading/error/data state, clean up to avoid race conditions and setState-after-unmount, and re-fetch when deps change. But for real apps, prefer React Query/SWR or framework data loading.
useEffect lets you run side effects after render — and data fetching is a side effect (it's not part of computing the UI, it talks to the outside world). So historically it's the place to fetch.
The basic pattern
function UserProfile({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => { if (!cancelled) setData(json); })
.catch((err) => { if (!cancelled && err.name !== "AbortError") setError(err); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; controller.abort(); }; // cleanup
}, [userId]); // re-fetch when userId changes
if (loading) return <Spinner />;
if (error) return <Error err={error} />;
return <Profile data={data} />;
}Why each piece matters
- The dependency array —
[userId]: fetch on mount and wheneveruserIdchanges.[]= once on mount. Wrong deps → stale data or refetch loops. - Three states —
loading,error,data. Skippingerroris the most common bug. - Cleanup is essential — without it you get two real bugs:
- Race condition —
userIdchanges from A to B; both requests are in flight; A resolves after B → you show A's data for B. Thecancelledflag (or aborting) discards the stale response. - setState after unmount — component unmounts before the fetch resolves; setting state on it warns/leaks. The flag guards against it.
- AbortController actually cancels the in-flight request, not just ignores it.
Why useEffect-fetching is discouraged for real apps
It works, but you end up hand-rolling: loading/error state, cancellation, caching, dedup, refetch-on-focus, retries, pagination — for every fetch. That's a lot of boilerplate and bug surface. So:
- React Query / SWR — purpose-built: caching, dedup, background refetch, retries, stale-while-revalidate, all the race/cleanup handling done for you.
- Framework data loading — Next.js Server Components / route loaders, React Router loaders — fetch before/outside render, no client waterfall.
- React's own guidance now: "you might not need an Effect" —
useEffectis for synchronizing with external systems, and data fetching is better served by a dedicated layer.
How to answer
"useEffect runs after render, so it's the classic spot to fetch on mount or when a dependency changes — you set loading/error/data state and, critically, return a cleanup that cancels or ignores stale responses to avoid race conditions and setState-after-unmount. But for production I'd use React Query or SWR, or framework data loading — useEffect fetching means hand-rolling caching, dedup, and cancellation for every call. React's current guidance is that Effects are for syncing with external systems, and data fetching has better tools."
Follow-up questions
- •What race condition does the cleanup function prevent?
- •Why is React Query preferred over useEffect for fetching?
- •What goes wrong with an incorrect dependency array?
- •What does 'you might not need an Effect' mean for data fetching?
Common mistakes
- •No cleanup — race conditions and setState-after-unmount.
- •Forgetting the error state.
- •Wrong dependency array — stale data or infinite refetch loops.
- •An async function passed directly to useEffect (it must return a cleanup, not a promise).
- •Hand-rolling fetch everywhere instead of using a data layer.
Performance considerations
- •useEffect fetching runs after render and on the client, often causing waterfalls. React Query dedupes and caches; framework loaders fetch before render. AbortController frees the network resource of stale requests.
Edge cases
- •Rapidly changing deps causing overlapping requests.
- •Component unmounting mid-request.
- •Dependency that's an unstable object/function reference causing refetch loops.
- •StrictMode double-invoking effects in development.
Real-world examples
- •A profile component fetching by userId with cleanup-guarded race handling.
- •Migrating useEffect+fetch to React Query and deleting most of the boilerplate.