Skip to content

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?

UserProfile.js
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.

Race Condition Timeline
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 DISPLAYED

I had to add cleanup:

Fixing Race Condition with AbortController
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.

Network Tab Without Caching
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.

Duplicate Requests
// UserProfile.js
function UserProfile({ userId }) {
useEffect(() => { fetchUser(userId) }, [userId]);
}
// UserHeader.js
function UserHeader({ userId }) {
useEffect(() => { fetchUser(userId) }, [userId]); // Same request!
}
// UserSidebar.js
function 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:

Repetitive Loading State Management
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 request

What the Community Says

The Reddit discussion made it clear I wasn’t imagining these problems:

CommentScoreInsight
”It depend, some still need use effect. You can extract to use some lib like tanstack Query or else or make your own hook.”19TanStack 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.”19Fetching 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.”3Separation 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”1Data 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:

UserProfile with TanStack Query
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

TanStack Query Handling Race Conditions
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 matches

Built-in Caching

Caching Configuration
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

Multiple Components, Single Request
// 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

Keep Data Fresh
useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
refetchOnWindowFocus: true, // Refetch when user returns to tab
refetchInterval: 60000, // Refetch every minute
});

Mutation and Cache Invalidation

Updating Data
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});
// Or optimistic updates
const 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:

SWR Example
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:

Legitimate useEffect Use Cases
// 1. Subscriptions
useEffect(() => {
const subscription = eventEmitter.subscribe(handler);
return () => subscription.unsubscribe();
}, []);
// 2. Timer-based operations
useEffect(() => {
const timer = setInterval(syncData, 30000);
return () => clearInterval(timer);
}, []);
// 3. Browser API integration
useEffect(() => {
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:

Migration Timeline
Week 1: New features use TanStack Query
Week 2: Extract highest-traffic components
Week 3: Extract components with complex loading states
Week 4: Remove remaining useEffect data fetching
Week 5: Delete manual cache/state management code

The reduction in code was dramatic:

Code Reduction Metrics
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 conditions

What I Wish I Knew Earlier

  1. 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.

  2. Data fetching is server state, not client state - TanStack Query treats server data differently from UI state. This mental model shift made everything clearer.

  3. 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.

  4. 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