useEffect vs TanStack Query: Why You Shouldn't Fetch Data with useEffect

When Chris was first learning React, this is how he was taught to fetch data:
"If you want to call an API when a component mounts, use useEffect."
So, Chris's code always looked something like this:
// UserList.tsx
function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let ignore = false; // Flag to prevent Race Conditions
const fetchUsers = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/users');
const data = await response.json();
if (!ignore) setUsers(data);
} catch (err) {
if (!ignore) setError(err as Error);
} finally {
if (!ignore) setIsLoading(false);
}
};
fetchUsers();
return () => { ignore = true; }; // Cleanup
}, []);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error occurred!</div>;
return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}It works. But he finds himself copy-pasting this boilerplate code into every single page.
A bigger problem arises when the user leaves the page and comes back: the loading spinner spins again, even though the data is exactly the same as it was a second ago.
To solve these inefficiencies, TanStack Query (formerly React Query) was introduced. Today, we’ll explore why we should ditch useEffect and adopt a server state management library.
1. useEffect is Not a Data Fetching Tool
Even the official React documentation admits this. useEffect is a tool for synchronizing with external systems, not for data fetching. When you use it for fetching, you run into fatal issues.
❌ Race Conditions
Let's say a user clicks the 'User A' button and immediately clicks the 'User B' button.
Depending on network conditions, the response for 'User B' might arrive first, followed by the response for 'User A'. The result? The UI shows that 'User B' is selected, but the information displayed is overwritten by 'User A'. This is a terrible bug.
To prevent this, you have to use an ignore flag (as seen in the example above) or an AbortController, which makes the code significantly more complex.
❌ No Caching
useEffect stores state inside the component (useState). When the component unmounts, that state disappears with it.
If you navigate back, the browser downloads the data from scratch. This wastes the user's data and battery life.
2. Server State vs. Client State
We've been treating data fetched from the server as if it were our own by putting it into useState. But strictly speaking, it’s not ours.
The core of handling server state is to "Fetch," "Cache," and "Sync." Trying to implement this manually with useEffect is like reinventing the wheel.
3. TanStack Query: Declarative Data Management
When you introduce TanStack Query, Chris's code changes like this:
// UserList.tsx (Refactored)
import { useQuery } from '@tanstack/react-query';
function UserList() {
// ✅ Automatically manages 3 states (data, isPending, error)
const { data: users, isPending, error } = useQuery({
queryKey: ['users'], // Unique identifier for the data
queryFn: async () => {
const res = await fetch('/api/users');
return res.json();
},
});
if (isPending) return <div>Loading...</div>;
if (error) return <div>Error occurred!</div>;
return <div>{users?.map((u: User) => <div key={u.id}>{u.name}</div>)}</div>;
}The code has been drastically reduced. But the real benefits lie beneath the surface.
✨ Automatic Caching and Deduplication
What if the UserList component and a UserSidebar component render simultaneously?
With useEffect, two separate API requests would be fired.
However, useQuery detects that they share the same queryKey (['users']) and sends only one API request. It then distributes the fetched data to both components.
✨ Stale-While-Revalidate
Suppose the user goes to another page and returns. TanStack Query immediately shows the cached data (Stale), eliminating the loading spinner. Then, in the background, it fetches the latest data and seamlessly swaps it in.
The user perceives this as, "Wow, this site is incredibly fast."
Key Takeaways
Now we know why we should use TanStack Query. But when should we re-fetch the data? Some data spoils in 5 seconds, while other data stays fresh for an hour.
Distinguishing clearly between staleTime and gcTime—the core options for setting these expiration dates—is the first step to mastering React Query.
Continuing in: "TanStack Query Lifecycle: The Difference Between staleTime and gcTime"