
TanStack Query vs useEffect : Stop Solving Problems You Don't Have
Every few years, the React ecosystem introduces a "must-have" library. Redux. MobX. Recoil. Apollo. SWR. TanStack Query.
Every new tool arrives with a promise to simplify development. Yet, paradoxically, many projects end up becoming more complicated than the problems they were originally trying to solve. Developers often adopt these tools without fully understanding the specific friction they are designed to eliminate.
When it comes to data fetching, the real question isn't: "Should I use TanStack Query?"
It is: "What problem am I actually trying to solve?"
This article isn't just a comparison of APIs. It’s a guide to shifting your mental model from blindly adopting tools to understanding the fundamental differences between client-side effects and server state.
React's Actual Job
React's job is simple: it turns state into UI.
Fetching data, caching it, retrying failed requests, and keeping it up to date are not React's responsibility. That's why libraries like TanStack Query exist.
Because React doesn't provide a built-in data-fetching abstraction, developers historically started fetching data inside useEffect. Developers used useEffect because it worked—not because it was the best tool for managing server data.
The "Aha!" Moment: When Fetching Breaks
To understand why we need specialized tools, we first need to visualize how a React application grows over time.
When you start a project, fetching data feels trivial. But as the app scales, the cracks begin to show:
11 API Call
2│
3├── useEffect ✅ (Perfectly fine)
4│
55 API Calls
6│
7├── Still manageable
8│
920 API Calls
10│
11├── Loading state everywhere
12├── Duplicate requests
13├── Manual refetching
14├── Repeated code
15│
16100 API Calls
17│
18└── Time for TanStack QueryAt 1 API call, useEffect is all you need. But at 100 API calls, manual fetching becomes a maintenance nightmare. This progression is exactly why server-state libraries were invented.
Why Manual Fetching Gets Messy
Let’s look at what a "proper" data fetch inside a useEffect actually looks like when you account for edge cases:
1
2useEffect(() => {
3 let cancelled = false;
4
5 async function load() {
6 try {
7 setLoading(true);
8 const data = await api.getUsers();
9
10 if (!cancelled) {
11 setUsers(data);
12 }
13 } catch (e) {
14 if (!cancelled) {
15 setError(e);
16 }
17 } finally {
18 if (!cancelled) {
19 setLoading(false);
20 }
21 }
22 }
23
24 load();
25
26 return () => { cancelled = true; };
27}, []);A single API call means you now have to manage:
- Loading
- Errors
- Cleanup
- Duplicate requests
It doesn't seem like much until your app has dozens of API calls. Suddenly, you are copy-pasting this boilerplate across every component, trying to manage race conditions, stale data, and background refreshes manually.
What TanStack Query Actually Solves
TanStack Query doesn't replace fetch().
It manages everything around your API calls—caching, refetching, retries, and keeping data in sync.
Instead of just executing a network request, TanStack Query acts as a dedicated manager for your server data. It handles request deduplication, background updates, and optimistic UI changes out of the box. It isn't just a fetching library; it is a server state management library.
Server State vs. Client State
To truly understand when to use TanStack Query, you need to recognize the two distinct types of state in your application.
There are two types of state in React:
Client state is data your app owns.
- Theme (light/dark mode)
- Modal open/close
- Form inputs
- Sidebar collapsed
Server state is data that comes from an API.
- Users
- Products
- Orders
- Notifications
React manages client state well. TanStack Query helps manage server state. Mixing them up—like trying to store UI state in a global server cache—is one of the most common mistakes developers make.
The Mental Model: useQuery vs useMutation
One of the biggest mistakes beginners make is trying to memorize the entire TanStack Query API. Instead, remember one simple rule:
Reading data? → useQuery → GET
Changing data?→ useMutation →POST PUT PATCH DELETE
If you are fetching a list of users, that's a useQuery. If you are submitting a form to create a new user, that's a useMutation. Once you internalize this distinction, the rest of the library becomes incredibly intuitive.
Step-by-Step: From Manual to TanStack Query
Let’s look at how this mental model changes your actual code.
The Manual Approach
Imagine a component that displays a list of users. Without TanStack Query, the component is responsible for the entire lifecycle:
1
2function Users() {
3 const [users, setUsers] = useState<User[]>([]);
4 const [loading, setLoading] = useState(true);
5 const [error, setError] = useState<Error | null>(null);
6
7 useEffect(() => {
8 let ignore = false;
9
10 async function loadUsers() {
11 try {
12 setLoading(true);
13 const response = await fetch("/api/users");
14 if (!response.ok) throw new Error("Failed to fetch");
15
16 const data = await response.json();
17 if (!ignore) setUsers(data);
18 } catch (err) {
19 if (!ignore) setError(err as Error);
20 } finally {
21 if (!ignore) setLoading(false);
22 }
23 }
24
25 loadUsers();
26 return () => { ignore = true; };
27 }, []);
28
29 if (loading) return <Spinner />;
30 if (error) return <ErrorMessage error={error} />;
31
32 return <UserList users={users} />;
33}The component is making the request, managing loading/error states, handling cancellation, and preventing post-unmount updates. It's noisy and repetitive.
The TanStack Query Approach
Now, let's refactor this using the mental model we just discussed:
1
2function Users() {
3// Reading data -> useQuery
4const { data: users, isLoading, error } = useQuery({
5 queryKey: ["users"],
6 queryFn: getUsers,
7 });
8
9if (isLoading) return <Spinner />;
10if (error) return <ErrorMessage error={error} />;
11
12return <UserList users={users} />;
13}That’s it. Your component now only describes what data it needs. TanStack Query handles how that data stays synchronized.
The Philosophy of Complexity
Don't add TanStack Query just because everyone else does. Every library adds complexity. If your app only makes one or two API calls, useEffect is often enough.
Use TanStack Query when it removes more code than it adds. Every dependency you introduce carries a hidden cost: future version upgrades, additional documentation to read, potential bugs, and onboarding time for new team members. Tools should remove code, not create ceremonies.
Key Takeaway
A great engineer doesn't choose the newest tool just because it's trending. They choose the simplest tool that solves today's problem while leaving room for tomorrow's scale.
useEffect isn't obsolete. TanStack Query isn't mandatory. They are simply different tools designed to solve different problems.
Understanding the distinction between client-side effects and server state—and knowing exactly when to apply each—is far more valuable than memorizing the API of the latest library.



