The Scene
It's 2 AM. You're staring at your screen, debugging why your dashboard keeps showing yesterday's data even after you've changed the filter. Your useEffect dependency array looks like a crime scene. You've got three useState hooks just to manage loading, error, and data. You added a cleanup function, but somehow the component still throws that dreaded warning: "Can't perform a React state update on an unmounted component."
You take a sip of cold coffee. You wonder where it all went wrong.
The Problem with useEffect for Data Fetching
Let's be honest with ourselves. useEffect was never designed for data fetching. The React team gave us this hook to synchronize with external systems, DOM events, subscriptions, and timers. But somewhere along the line, we collectively decided to use it as our go-to tool for API calls.
And look, I get it. When you're learning React, the pattern is simple:
useEffect(() => {
const fetchData = async () => {
setLoading(true);
const response = await fetch('/api/users');
const data = await response.json();
setUsers(data);
setLoading(false);
};
fetchData();
}, []);
Enter fullscreen mode Exit fullscreen mode
It works. Until it doesn't.
Here's what happens when your application grows:
Race Conditions — When your user clicks filters too quickly, old requests return after newer ones and override your state. The UI shows mismatched data, and you waste hours adding request cancellation logic that nobody on your team fully understands.
Unnecessary Re-renders — Every state update triggers a re-render. With useEffect, you're juggling at least three states: data, loading, and error. Three states, three renders, even before React mounts your actual content.
Poor Caching — If a user visits a page, leaves, and comes back, your useEffect fires again. Same data, same API call, same network cost. Multiply this by a thousand users, and you're burning your backend for no good reason.
Manual Cleanup Headaches — Need to cancel pending requests? Need to prevent state updates after unmount? You'll write boilerplate that distracts from your actual business logic.
Enter TanStack Query (Formerly React Query)
Now, imagine a different approach. Instead of telling React how to fetch data, you simply tell it what data you need:
const { data, isLoading, error } = useQuery({
queryKey: ['users', filter],
queryFn: () => fetchUsers(filter),
});
Enter fullscreen mode Exit fullscreen mode
That's it. No useState. No useEffect. No cleanup. Just a declarative statement: "Give me users based on this filter, and handle all the complexity for me."
What TanStack Query Gives You That useEffect Never Will
Automatic Caching — When a user navigates away and returns, the data is already there. No additional network requests. Your application feels fast because it is fast.
Deduplication — If two components request the same data simultaneously, TanStack Query makes a single request and shares the response. Your backend thanks you.
Background Refetching — Stale data automatically updates in the background. Your users always see fresh information without spinners interrupting their flow.
Retry Logic — Network failure? Server error? TanStack Query retries intelligently with exponential backoff. You don't have to write that logic yourself.
Optimistic Updates — When a user submits a form, you can update the UI immediately and sync with the server in the background. The experience feels instant.
Devtools — A Chrome extension that shows you exactly what's cached, what's fetching, and what's stale. No more guessing why data isn't showing.
The Real Difference
Here's the thing: when you use useEffect, you're managing imperative side effects. You're telling React: "When this component renders, do this thing, then clean up when it unmounts."
When you use TanStack Query, you're managing declarative state. You're telling React: "I need this data. Handle the rest."
One approach makes you a plumber, constantly fixing leaks. The other makes you an architect, designing systems that work.
A Practical Example
Consider a dashboard with filters, pagination, and auto-refresh. With useEffect, your code grows into a tangled mess of dependencies:
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(
`/api/sales?page=${page}&filter=${filter}`,
{ signal: controller.signal }
);
const data = await response.json();
if (isMounted) setSales(data);
} catch (error) {
if (isMounted && error.name !== 'AbortError') {
setError(error);
}
} finally {
if (isMounted) setLoading(false);
}
};
fetchData();
const interval = setInterval(fetchData, 5000);
return () => {
isMounted = false;
controller.abort();
clearInterval(interval);
};
}, [page, filter]);
Enter fullscreen mode Exit fullscreen mode
Look at that. Twenty lines of boilerplate just to fetch data with cleanup, cancellation, and polling.
Now the TanStack Query version:
const { data, isLoading, error } = useQuery({
queryKey: ['sales', page, filter],
queryFn: () => fetchSales(page, filter),
refetchInterval: 5000,
});
Enter fullscreen mode Exit fullscreen mode
Eight lines. Clean. Readable. Maintainable.
But Wait—Is useEffect Completely Obsolete?
No. Let's not throw the baby out with the bathwater.
useEffect still has valid use cases:
- Synchronising with non-React systems — Integrating with third-party libraries like Google Maps, Chart.js, or Stripe elements.
- Adding event listeners — Window resize, scroll position, keyboard shortcuts.
- Managing animations — Triggering transitions when props change.
- Logging analytics — Tracking page views and user interactions.
The key distinction is this: useEffect is for synchronisation, not data fetching. If your effect reaches out to a server, you're probably using the wrong tool.
What You Gain by Making the Switch
Faster Development — Less boilerplate means more time building features that matter to your users.
Fewer Bugs — Race conditions, memory leaks, and stale data become problems of the past.
Better User Experience — Instant navigation, background updates, and optimistic mutations make your app feel native.
Reduced Server Load — Intelligent caching cuts your API calls by 60–80%.
Easier Onboarding — New developers grasp the data flow in minutes rather than days.
A Word for the Sceptics
I know what some of you are thinking: "Another library? I'm tired of learning new things."
But here's the truth—this isn't just a library. It's a paradigm shift. It's acknowledging that we've been doing data fetching the hard way for too long.
TanStack Query has been battle-tested in production for years. It powers applications at companies you know and respect. It's not going anywhere.
And once you learn it, you'll wonder how you ever lived without it.
The Bottom Line
Your useEffect code isn't working. It's working enough, which is different.
You've been surviving, not thriving.
You've been fixing bugs that shouldn't exist.
You've been writing code that future you will curse.
Stop making your life difficult. Stop making your users suffer. Stop making your backend engineers cry.
The tools exist. The knowledge is available. The only thing standing in your way is inertia.
Your Next Step
Tomorrow, open your codebase. Find a single useEffect that fetches data. Replace it with useQuery.
Time it. See how long it takes.
Then ask yourself: why didn't I do this sooner?
Because once you taste the difference, you'll never go back.
Your API is tired. Your users are waiting. Your team deserves better.
Make the switch today.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.