
I used to fetch data the "React way."
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, []);
It worked. I shipped it. I moved on.
Then I started noticing things. The same data fetched twice. Race conditions when navigating fast. Components flickering. Users seeing stale data after mutations.
I thought I was doing something wrong. Turns out, I was doing exactly what the tutorials taught me. The problem was the pattern itself.
The problems I discovered (the hard way)
1. No caching, ever
Every time a component mounts, it fetches. Navigate away? Come back? Fetch again. Same data, same endpoint, same waste.
// Page A fetches /api/users
// User navigates to Page B
// User navigates back to Page A
// Page A fetches /api/users AGAIN
I had users waiting for the same data they saw 2 seconds ago.
2. Race conditions are lurking
This looks fine:
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
Until a user clicks fast between profiles. Request for user 1 goes out. Request for user 2 goes out. User 2 responds first. User 1 responds second. Now you're showing user 1's data on user 2's profile.
The fix exists (AbortController), but nobody uses it:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [userId]);
That's a lot of boilerplate for something that should be automatic.
3. Refetching is manual
User adds a todo. Now you need to refetch the list. But the list is in another component. Now you're lifting state, passing callbacks, or reaching for context.
// TodoList.tsx
useEffect(() => {
fetchTodos().then(setTodos);
}, []);
// AddTodo.tsx
async function handleAdd() {
await createTodo(text);
// How do I tell TodoList to refetch?
// onSuccess prop? Context? Global state?
}
Every mutation becomes a coordination problem.
4. Loading states everywhere
Three useState calls per fetch. Every component. Every time.
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
I had components with 9 useState calls just for 3 different fetches.
5. No background updates
Data gets stale. User leaves tab open for 10 minutes, comes back, sees outdated info. With useEffect, you'd need to set up visibility listeners, intervals, all manually.
The moment I switched
I was building a dashboard. Multiple widgets, each fetching its own data. Some widgets shared the same endpoint. Some needed to refetch after mutations in other widgets.
My code was a mess. State lifted everywhere. Callbacks passed through 4 levels. Race condition bugs I couldn't reproduce consistently.
A colleague said: "Just use React Query."
I resisted. "I don't need another library. useEffect works fine."
He showed me this:
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json())
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<ul>
{data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
No useState. No useEffect. No AbortController.
And then:
function AddUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUser) => fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser)
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
return (
<button onClick={() => mutation.mutate({ name: 'John' })}>
Add User
</button>
);
}
When the mutation succeeds, every component using ['users'] refetches automatically. No props. No callbacks. No context.
I was sold.
What TanStack Query gives you for free
Automatic caching
Same queryKey = same cache. Multiple components can use useQuery(['users']) and only one request goes out.
// Component A
const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// Component B (renders at the same time)
const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// Result: ONE fetch, both components get the data
Automatic deduplication
Request in flight? New component mounts with the same query? It waits for the existing request instead of firing a duplicate.
Stale-while-revalidate
Show cached data immediately, refetch in background, update when fresh data arrives. Users see content instantly.
Smart refetching
Data refetches when:
- Window regains focus
- Network reconnects
- You invalidate the query
- Configurable stale time expires
All automatic. All configurable.
DevTools
See every query, its status, its cache, when it was fetched. Debug data issues in seconds.
The code comparison
Before (useEffect):
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
After (TanStack Query):
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
Same functionality. Half the code. Plus caching, deduplication, and background refetching.
When I still use useEffect
I didn't delete useEffect from my vocabulary. It still has its place:
- Event listeners: window resize, keyboard shortcuts
- Subscriptions: WebSocket connections, real-time updates
- DOM manipulation: focus management, scroll position
- Timers: intervals, timeouts
- Third-party libraries: initializing charts, maps, etc.
The rule I follow now: if it's fetching data from an API, it's not a useEffect job.
What about Server Components?
If you're using Next.js App Router or any RSC-enabled framework, you have another option: fetch directly in server components.
// This runs on the server
async function UserList() {
const users = await fetch('/api/users').then(res => res.json());
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
No hooks. No client-side fetching. Data loads before the component even reaches the browser.
For client-side interactivity (mutations, optimistic updates, real-time data), TanStack Query still shines. They complement each other:
- Server Components for initial data
- TanStack Query for client-side mutations and cache management
What about React 19's use() hook?
React 19 introduced use(), a new way to read promises directly in components:
function UserProfile({ userPromise }) {
const user = use(userPromise); // Suspends until resolved
return <h1>{user.name}</h1>;
}
// Parent passes the promise
function App() {
const userPromise = fetchUser(userId);
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
It's cleaner than useEffect for simple cases. But it doesn't replace TanStack Query for:
- Caching across components
- Background refetching
- Mutation handling
- Query invalidation
Think of use() as a building block. TanStack Query is the full solution.
I covered use() and all the other React 19 hooks in detail in part 5 of my React hooks series.
The lesson
useEffect isn't bad. It's just not built for data fetching.
I spent months fighting symptoms: race conditions, stale data, refetch coordination, boilerplate. The real problem was using a synchronization tool for a caching problem.
TanStack Query isn't magic. It's just the right tool for the job. And once I accepted that "just use useEffect" wasn't the answer, my code got simpler, my bugs got fewer, and my users got a faster app.
Sometimes the hard way is the only way to learn why the easy way exists.
This is part 1 of my "What I learned the hard way" series. Next up: the performance mistakes I made on my first real app.
Continue to part 2: The performance mistakes I made on my first real app →
Got questions? Hit me up on LinkedIn or check out more on my blog.