Why Stop Using useEffect for Data Fetching and What Are the Alternatives
Problem
I spent three hours debugging a race condition in my React app last week. The bug was subtle: when a user quickly switched between user profiles, the wrong data would sometimes appear. The profile for User B would show User A’s information.
After adding console.log statements everywhere, I traced it to my useEffect hook. I was using useEffect to fetch data, and the rapid prop changes were causing requests to return out of order. The fix was simple—add an abort controller. But the real question bothered me: why was I manually managing all this complexity?
A Reddit thread captured my exact frustration:
“If prop A changed, i had an effect to update state B. which naturally triggered another effect to fetch some data, which updated state C”
This cascade of effects was my waking nightmare. And I wasn’t alone.
Why useEffect Feels Natural (But Isn’t)
When I first learned React hooks, useEffect seemed like the perfect place for data fetching. It runs after render, so I can fetch data and update state. Simple, right?
function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null);
useEffect(() => { async function fetchUser() { try { setLoading(true); const response = await fetch(`/api/users/${userId}`); const data = await response.json(); setUser(data); } catch (err) { setError(err); } finally { setLoading(false); } } fetchUser(); }, [userId]);
if (loading) return <Spinner />; if (error) return <Error message={error.message} />; return <UserCard user={user} />;}This looks reasonable. But it has at least five hidden problems that I learned about the hard way.
The Hidden Problems
Problem 1: Race Conditions
When userId changes rapidly, multiple fetches can be in flight simultaneously. The last request to complete might not be the last one started.
Time 0: userId = 'user-a' -> Request A starts
Time 100: userId = 'user-b' -> Request B starts -> Request A still in progress
Time 200: Request B completes -> User B data renders
Time 300: Request A completes (slower network) -> User A data overwrites User B! -> WRONG DATA DISPLAYEDI had to add cleanup:
useEffect(() => { const controller = new AbortController();
async function fetchUser() { try { const response = await fetch(`/api/users/${userId}`, { signal: controller.signal }); const data = await response.json(); setUser(data); } catch (err) { if (err.name !== 'AbortError') { setError(err); } } }
fetchUser(); return () => controller.abort();}, [userId]);This works, but I had to write it myself. Every component. Every time.
Problem 2: No Caching
With useEffect, every navigation away and back triggers a new fetch. I saw the same data requested three times in my network tab just from opening and closing a modal.
GET /api/users/user-a -> 200 OK (cached? NO)GET /api/users/user-a -> 200 OK (cached? NO) <- Duplicate!GET /api/users/user-a -> 200 OK (cached? NO) <- Duplicate!I could add my own cache, but now I’m building a state management library inside my component.
Problem 3: No Deduplication
When multiple components need the same data, each triggers its own fetch.
// UserProfile.jsfunction UserProfile({ userId }) { useEffect(() => { fetchUser(userId) }, [userId]);}
// UserHeader.jsfunction UserHeader({ userId }) { useEffect(() => { fetchUser(userId) }, [userId]); // Same request!}
// UserSidebar.jsfunction UserSidebar({ userId }) { useEffect(() => { fetchUser(userId) }, [userId]); // Same request!}Three components, three network requests for the exact same data. I had to lift state up or introduce a caching layer—more manual work.
Problem 4: No Background Refetching
Data becomes stale immediately. When a user returns to a tab after 5 minutes, they see old data. I had no way to know if the data was still valid.
Problem 5: Complex Loading States
Every component needs the same boilerplate:
const [loading, setLoading] = useState(false);const [error, setError] = useState(null);const [data, setData] = useState(null);
// Plus try/catch/finally in every useEffect// Plus abort controller cleanup// Plus state management for every requestWhat the Community Says
The Reddit discussion made it clear I wasn’t imagining these problems:
| Comment | Score | Insight |
|---|---|---|
| ”It depend, some still need use effect. You can extract to use some lib like tanstack Query or else or make your own hook.” | 19 | TanStack Query is the industry standard recommendation |
| ”This is why pure components are so important. Don’t fetch data at all, get it from the props.” | 19 | Fetching should happen outside React runtime when possible |
| ”A lot of these issues arise from having stuff that doesn’t belong in the UI layer. Shared data, business logic, network code etc. all belong outside React runtime.” | 3 | Separation of concerns |
| ”Whether or not someone has graduated past ‘using effects for everything’ is in my experience the biggest indicator for whether they actually understand React” | 1 | Data fetching patterns indicate React competency |
The key insight: useEffect is a synchronization primitive, not a data fetching tool. It synchronizes React state with external systems. Data fetching is a side effect, but that doesn’t mean useEffect is the right tool.
The Solution: TanStack Query
TanStack Query (formerly React Query) solves all these problems. Here’s the same component rewritten:
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) { const { data: user, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()), });
if (isLoading) return <Spinner />; if (error) return <Error message={error.message} />; return <UserCard user={user} />;}That’s it. No useEffect. No manual state management. No abort controllers. What does TanStack Query give me automatically?
Automatic Race Condition Prevention
Time 0: userId = 'user-a' -> Request A starts -> Active query key: ['user', 'user-a']
Time 100: userId = 'user-b' -> Request B starts -> Active query key: ['user', 'user-b'] -> Request A is automatically ignored when it completes because its key no longer matchesBuilt-in Caching
useQuery({ queryKey: ['user', userId], queryFn: fetchUser, staleTime: 5 * 60 * 1000, // Data fresh for 5 minutes cacheTime: 30 * 60 * 1000, // Keep in cache for 30 minutes});Requests within 5 minutes return cached data instantly. No manual cache implementation.
Automatic Deduplication
// All three components share the same query// Only ONE network request is made
function UserProfile({ userId }) { const { data } = useQuery({ queryKey: ['user', userId], // Same key queryFn: fetchUser, });}
function UserHeader({ userId }) { const { data } = useQuery({ queryKey: ['user', userId], // Same key - deduplicated! queryFn: fetchUser, });}Background Refetching
useQuery({ queryKey: ['user', userId], queryFn: fetchUser, refetchOnWindowFocus: true, // Refetch when user returns to tab refetchInterval: 60000, // Refetch every minute});Mutation and Cache Invalidation
const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: updateUser, onSuccess: () => { // Invalidate and refetch queryClient.invalidateQueries({ queryKey: ['user'] }); },});
// Or optimistic updatesconst mutation = useMutation({ mutationFn: updateUser, onMutate: async (newUser) => { await queryClient.cancelQueries({ queryKey: ['user', userId] }); const previousUser = queryClient.getQueryData(['user', userId]); queryClient.setQueryData(['user', userId], newUser); return { previousUser }; }, onError: (err, newUser, context) => { queryClient.setQueryData(['user', userId], context.previousUser); },});Alternative: SWR
SWR (stale-while-revalidate) from Vercel is another excellent option with a simpler API:
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then((r) => r.json());
function UserProfile({ userId }) { const { data: user, error, isLoading } = useSWR(`/api/users/${userId}`, fetcher);
if (isLoading) return <Spinner />; if (error) return <Error message={error.message} />; return <UserCard user={user} />;}SWR is slightly more lightweight and opinionated. TanStack Query has more features (mutations, devtools, broader ecosystem).
When to Still Use useEffect
useEffect is not evil. It’s just misused. The legitimate use cases:
// 1. SubscriptionsuseEffect(() => { const subscription = eventEmitter.subscribe(handler); return () => subscription.unsubscribe();}, []);
// 2. Timer-based operationsuseEffect(() => { const timer = setInterval(syncData, 30000); return () => clearInterval(timer);}, []);
// 3. Browser API integrationuseEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);}, []);These are synchronization operations, not data fetching. They set up and tear down subscriptions to external systems.
Migration Strategy
I migrated gradually:
Week 1: New features use TanStack QueryWeek 2: Extract highest-traffic componentsWeek 3: Extract components with complex loading statesWeek 4: Remove remaining useEffect data fetchingWeek 5: Delete manual cache/state management codeThe reduction in code was dramatic:
Before (useEffect):- 150 lines per data-fetching component- Manual error handling- Manual loading states- No caching- No deduplication- Race condition bugs
After (TanStack Query):- 30 lines per data-fetching component (80% reduction)- Automatic error handling- Automatic loading states- Built-in caching- Automatic deduplication- No race conditionsWhat I Wish I Knew Earlier
-
useEffect is for synchronization, not data fetching - The React team has said this repeatedly, but I didn’t internalize it until I hit the bugs.
-
Data fetching is server state, not client state - TanStack Query treats server data differently from UI state. This mental model shift made everything clearer.
-
The “complexity” of TanStack Query is actually simplicity - I avoided it thinking it added complexity. But the code I was writing manually was far more complex and buggy.
-
Start with the library, not the manual implementation - I thought I could build a simple version myself. I couldn’t. The edge cases are numerous.
Summary
In this post, I explained why useEffect is a poor choice for data fetching in React and demonstrated modern alternatives that handle the complexity automatically. The key point is that useEffect is a synchronization primitive, not a data fetching tool. Using it for data fetching leads to race conditions, missing error handling, no caching, duplicate requests, and complex loading state management. TanStack Query and SWR solve all these problems out of the box with significantly less code. The migration from manual useEffect data fetching to TanStack Query reduced my data-fetching components from ~150 lines to ~30 lines while eliminating an entire class of bugs. For new projects, start with a data fetching library. For existing projects, migrate gradually, starting with the highest-traffic components.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments