How do you use useEffect to fetch data from an API in React?
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.