🔄 Data Fetching in React — useEffect, React Query & Best Practices
Fetch data in React the right way. useEffect basics, race conditions, AbortController, loading/error states, and why libraries like React Query are usually a better choice.
React doesn't ship a data-fetching API. Your options:
- useEffect + fetch — fine for simple cases
- React Query (TanStack Query) — caching, retries, dedup, refetch on focus — production default
- SWR — similar idea, simpler API
- RTK Query — if you already use Redux Toolkit
- React Router data APIs — loaders/actions tied to routes
🧪 The DIY pattern (useEffect)
function User({ id }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const ctrl = new AbortController();
setLoading(true);
fetch(`/api/users/${id}`, { signal: ctrl.signal })
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(setUser)
.catch(e => { if (e.name !== 'AbortError') setError(e); })
.finally(() => setLoading(false));
return () => ctrl.abort();
}, [id]);
if (loading) return <Spinner />;
if (error) return <ErrorBox err={error} />;
return <Profile user={user} />;
}⚠️ Race conditions
If id changes from A → B quickly, response A might land AFTER B's, overwriting the right data. Cancel with AbortController in cleanup.
💎 Why React Query wins for real apps
- Automatic caching + dedup
- Retries on failure
- Refetch on window focus / reconnect
- Built-in loading/error/success state
- Pagination + infinite scroll utilities
import { useQuery } from '@tanstack/react-query';
const { data, isLoading, error } = useQuery({
queryKey: ['user', id],
queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()),
});💻 Code Examples
DIY with abort + cleanup
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/search?q=${query}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setResults)
.catch(e => { if (e.name !== 'AbortError') setError(e); });
return () => ctrl.abort();
}, [query]);Output:
Each new query cancels the previous in-flight request — no race.
React Query equivalent
const { data: results, isLoading, error } = useQuery({
queryKey: ['search', query],
queryFn: ({ signal }) => fetch(`/api/search?q=${query}`, { signal }).then(r => r.json()),
enabled: !!query,
staleTime: 60_000,
});Output:
Same behavior, with caching, dedup, retries — and one-third the code.
⚠️ Common Mistakes
- Forgetting to handle race conditions when params change quickly.
- Not setting loading: true at the START of the effect — old data lingers visibly.
- Calling fetch directly in render — fires every render, infinite loop.
- Skipping error handling — a failed network request silently breaks the UI.
- Storing server data manually in Context — re-invents what React Query already does well.
🎯 Interview Questions
Real questions asked at top product and service-based companies.
Q1.How do you handle race conditions in useEffect fetches?Intermediate
Use AbortController: create one in the effect, pass `signal` to fetch, and call `ctrl.abort()` in the cleanup function. The next effect's fetch starts after the previous one is canceled — so only the latest response updates state.
Q2.Why use React Query or SWR over plain useEffect?Intermediate
They give you caching, deduplication, retries, refetch-on-focus, pagination helpers, and structured loading/error/success states out of the box. You write less code and get better UX.
Q3.Where should you put a fetch call in React?Beginner
Inside useEffect (or a custom hook that wraps it). Never directly in the function body — it would fire on every render. With a library, use the library's hook (useQuery, useSWR).
Q4.How do you handle dependent queries?Advanced
React Query: pass `enabled: !!firstQuery.data` to gate the second query until the first resolves. DIY: chain inside one effect with awaits, or use Promise composition.
Q5.What's optimistic update and how do you implement it?Advanced
Update local state to the expected result BEFORE the server responds, then reconcile on success (or revert on error). React Query: `onMutate` + `onError` rollback. Gives instant UI feedback for fast-feeling apps.
🧠 Quick Summary
- Fetch inside useEffect — never in render.
- Always include AbortController for cleanup.
- Track loading + error + data — all three states.
- React Query / SWR remove most of the boilerplate.
- Optimistic updates feel instant — reconcile on response.