Stop Using useEffect for Data Fetching: A Modern React Architecture Guide
Why React’s old data-fetching pattern breaks at scale — and what to use instead with Server Components, React Query, Suspense, and modern frameworks.
Senior Developer

If you review a React codebase written a few years ago, you will inevitably find components littered with code that looks exactly like this:
const UserProfile = ({ userId }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(userData => {
if (isMounted) {
setData(userData);
setLoading(false);
}
})
.catch(err => {
if (isMounted) {
setError(err.message);
setLoading(false);
}
});
return () => { isMounted = false; };
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage text={error} />;
return <div>{data.name}</div>;
};
This is how we were all taught to write React. It feels intuitive. You mount a component, trigger a side-effect to get data, and save it to state.
In 2024, this is universally considered an anti-pattern. The React core team actively advises against doing this.
Let's dissect exactly why this approach is fundamentally flawed, and how modern query libraries solve these problems elegantly.
The Four Fatal Flaws of useEffect Fetching
1. The Race Condition Nightmare
Imagine the user clicks "Profile A", and your useEffect fires a network request. It's taking a while. Impatient, the user clicks "Profile B". A second request fires.
If the server processes Profile B quickly, it returns first. The UI updates to Profile B. Then, the slow request for Profile A finally returns. The setState triggers, and the UI visually reverts to Profile A, even though the user is currently "looking" at Profile B!
You can fix this with isMounted boolean tracking flags or AbortController, but you are now writing complex async state machines manually.
2. The Strict Mode "Double Fetch"
If you upgrade to React 18, React's Strict Mode aggressively mounts, unmounts, and remounts your components in development to expose bugs. This means your useEffect fires twice, hammering your API backend and flooding your network tab.
3. Cache Misses and Waterfall Loading
When a user navigates from the Dashboard to the Settings page, the Dashboard unmounts. The data state is destroyed. When they navigate back 3 seconds later, the useEffect runs again. The user stares at a loading spinner for data they literally just saw.
Worse, if ParentComponent fetches a User, and ChildComponent fetches the User's Posts, they execute sequentially. The Child waits for the Parent to finish before it even starts fetching. This is the dreaded "Network Waterfall."
4. Boilerplate Hell
Tracking data, isLoading, and error variables requires dozens of lines of code. Multiply that across 50 components, and your codebase is drowning in repetitive state management.
The Solution: Specialized Query Libraries
Data fetching is a complex engineering problem involving caching, background revalidation, stale-time policies, and retry logic. You should not write this yourself.
The industry standard is to use a specialized library: TanStack Query (React Query) for REST APIs, or Apollo Client for GraphQL.
Here is the exact same component rewritten with Apollo Client:
import { useQuery, gql } from '@apollo/client';
const GET_USER = gql`
query GetUser($userId: ID!) {
user(id: $userId) {
id
name
}
}
`;
const UserProfile = ({ userId }) => {
const { data, loading, error } = useQuery(GET_USER, {
variables: { userId }
});
if (loading) return <Spinner />;
if (error) return <ErrorMessage text={error.message} />;
return <div>{data.user.name}</div>;
};
It is beautiful, concise, and eliminates 90% of the boilerplate. But the real power is under the hood.
Why this architecture is vastly superior:
1. Instant Cache Retrieval
Apollo Client and TanStack Query maintain a normalized, global memory cache. If you navigate away from the Dashboard and return, the library instantly serves the data from memory. There is zero loading spinner. It then silently triggers a background network request (stale-while-revalidate) and updates the UI if the data changed.
2. Automatic Race Condition Resolution
If userId changes rapidly, the library tracks the request IDs. It mathematically guarantees that only the data belonging to the latest request will ever be passed to the component, completely eliminating async race conditions.
3. Global State Synchronization
Because the cache is centralized, you don't need Redux or Context to share data.
If your Navbar component queries the user's name, and your ProfileSettings component executes a mutation to change that name, the cache updates instantly. The Navbar automatically re-renders with the new name without you writing a single line of state synchronization code.
4. Automatic Retries and Deduplication
If the user's internet drops while on a train, the query will automatically retry 3 times with exponential backoff before throwing an error. Furthermore, if three different components on the screen all call useQuery(GET_USER), the library deduplicates them and only sends one network request to your server.
Conclusion
useEffect was designed to synchronize React components with external systems (like attaching a DOM event listener or connecting to a WebSocket). It is a low-level primitive, not a data-fetching solution.
By adopting a robust Query library, you delete hundreds of lines of boilerplate, vastly improve the user experience with instant caching, and permanently eliminate the most common async bugs in React development.
Comments (0)
Login to post a comment.