Every React tutorial starts with this:
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(setData);
}, []);
It works. But it's a trap.
The problems
Race conditions. If the dependency changes fast (search input, tab switches), multiple requests fire. The last response might not be from the last request.
// Bug: typing "react" fires 5 requests
// Response for "r" might arrive after "react"
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, [query]);
No cleanup. You need an AbortController for cancellation. Most devs forget.
Loading and error states. You end up with 3-4 state variables (data, loading, error, isRefetching) that are easy to get wrong.
Waterfalls. Parent fetches, then renders children, then children fetch. Sequential when it could be parallel.
What I use instead
Server Components (Next.js App Router). Data fetching happens on the server. No loading states. No race conditions. The component renders with data already available.
// Just works. No useEffect, no loading state.
export default async function Dashboard() {
const data = await fetchDashboardData();
return <DashboardView data={data} />;
}
React Query / SWR for client-side cases where you genuinely need it (real-time updates, optimistic mutations). They handle caching, deduplication, and race conditions.
function SearchResults({ query }) {
const { data, isLoading } = useQuery({
queryKey: ['search', query],
queryFn: () => fetchSearch(query),
});
if (isLoading) return <Skeleton />;
return <ResultList items={data} />;
}
When useEffect is still fine
- Subscriptions (WebSocket, event listeners)
- DOM manipulation (focus, scroll position)
- Syncing external stores
For data fetching? Almost never anymore.