A few weeks ago, the React community was buzzing after the useEffect debacle at Cloudflare.
If you’re not aware of what happened, don’t worry — I’ll briefly explain it here.
But the key takeaway for me was this:
We’ve been misusing
useEffectfor data fetching all along.
Sure, useEffect can fetch data, but it quickly becomes messy — you end up juggling loading states, error handling, cleanup, race conditions, and caching manually. It’s boilerplate-heavy, error-prone, and far from ideal.
That’s where TanStack Query (formerly React Query) comes in.
Instead of reinventing the wheel, it gives us powerful, battle-tested hooks that handle loading, errors, caching, retries, and background refetching — all out of the box.
In this post, I’ll walk through:
- 🧩 What happened at Cloudflare
- ⚠️ Why
useEffectisn’t the right tool for data fetching - 💪 How TanStack Query makes your life as a React developer much easier
🧩 What Happened at Cloudflare?
So, what exactly went wrong?
In their own post-mortem, the Cloudflare team explained that a bug in their React dashboard caused massive unintended API traffic.
“…we mistakenly included a problematic object in its dependency array. Because this object was recreated on every state or prop change, React treated it as ‘always new,’ causing the
useEffectto re-run each time. As a result, the API call executed many times during a single dashboard render instead of just once.”
Think about that — a tiny mistake in a dependency array triggered a flood of API calls.
It didn’t just make the UI laggy; it completely overwhelmed their backend authorization service, leading to a major outage across multiple APIs.
This is the perfect example of why managing data fetching with useEffect is risky.
It puts the burden of dependency tracking, cleanup, and request control entirely on you — the developer.
TanStack Query eliminates this entire class of problems by design. It ensures that a simple re-render never floods your backend or breaks your app.
⚙️ The Traditional Way (Using useEffect)
Here’s a typical data-fetching example using useEffect.
Notice how much manual state management is involved.
import { useState, useEffect } from 'react';
// Simulated fetch function
const fetchUser = async () => {
const response = await fetch('https://api.github.com/users/deveshsangwan');
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
};
function UserProfile() {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
const getUser = async () => {
try {
const userData = await fetchUser();
if (isMounted) setUser(userData);
} catch (err) {
if (isMounted) setError(err.message);
} finally {
if (isMounted) setIsLoading(false);
}
};
getUser();
return () => { isMounted = false; };
}, []);
if (isLoading) return <div>Loading profile...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
🔍 Key Problems
- Manual state management (
isLoading,error,user) - Cleanup logic to prevent race conditions
- Repeated boilerplate across components
It works — but at scale, it’s fragile and repetitive.
✅ The TanStack Query Way (The “Right” Way)
Here’s the same functionality using TanStack Query.
Notice how much cleaner and more declarative it is.
First, set up the QueryClientProvider at the root of your app:
// App.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import UserProfile from './UserProfile';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<UserProfile />
</QueryClientProvider>
);
}
Now the component itself becomes drastically simpler:
import { useQuery } from '@tanstack/react-query';
const fetchUser = async () => {
const response = await fetch('https://api.github.com/users/deveshsangwan');
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
};
function UserProfile() {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', 'deveshsangwan'],
queryFn: fetchUser,
});
if (isLoading) return <div>Loading profile...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
Let's quickly break down the two core options we used in that useQuery hook:
queryKey: ['user', 'deveshsangwan']
This is the unique identifier for this query. TanStack Query uses it internally for caching and refetching. The key must be a serializable array, which is the direct solution to the Cloudflare issue. Instead of an unstable object reference in a dependency array, we use a stable, predictable key.
queryFn: fetchUser
This is simply the asynchronous function that fetches the data. It must return a promise that either resolves with the data or throws an error.
🌟 Benefits
- No
useEffector manual state needed - Built-in caching and background refetching
- Automatic handling of loading and error states
- Cleaner, more maintainable code
💭 Final Thoughts
The Cloudflare incident reminded the React community that just because something works doesn’t mean it’s right.
useEffect wasn’t designed for data fetching — and trying to force it into that role introduces subtle, hard-to-debug problems.
TanStack Query lets you focus on what matters — the UI — while handling the rest for you.
Once you switch, you’ll never want to go back.
Learn more on the official TanStack Query documentation.
Top comments (11)
Nice article. I liked it.
However, I have one big question, is it possible to combine redux with its asynchronous thunks (or another tool for centralization the data) and TanStack Query. And if possible, what is the best and elegant way?
Thanks! Glad you liked it 😊
Yes, you can use Redux and TanStack Query together since they serve different purposes.
Redux (or thunks) works best for client state such as UI elements, filters, and toggles.
TanStack Query is ideal for server state which comes from APIs and can change outside your app.
You can sync them by dispatching inside onSuccess, but it’s usually cleaner to let TanStack Query manage server data and keep Redux for UI logic.
Fine! Thank you for explanation, it's very clear and i appreciate it. Waiting for your new articles ☺️.
onSuccess callback has been removed since doing exactly this was considered an anti-pattern. tkdodo.eu/blog/breaking-react-quer...
Stating that redux is for client state and TanStack Query is also clumsy. Not redux neither TanStack Query states that. Also TanStack Query is not only for syncing server data. It's a state management library that's convenient for any asynchronous data and actually making reactive state from them. Instead of fetch, it could be anything async. E.g. MediaDevices.getUserMedia which returns Promise.
You’re right that onSuccess, onError, and onSettled were removed from useQuery in v5 to discourage side effects during render. But are still available and recommended for useMutation, since mutations represent explicit user actions.
(Docs: tanstack.com/query/latest/docs/fra...)
I agree that TanStack Query can handle any async data, not just HTTP calls. But its core design and documentation describe it as an async state library optimized for server state, meaning data that can become stale, needs caching, or background refetching.
Redux or Zustand works better for client or UI state, such as filters, toggles, or input. TanStack Query is not intended to replace that kind of local state management.
So while it can be used for any promise-based data, the practical distinction still makes sense:
TanStack Query - Server or async state
Redux - Client or UI state
That’s also the pattern most teams follow.
useMutation is totally different story, the article isn't about it at all, it does not serve as wrapper around data synced into the client app. It rather helps monitor event based async requests.
The thing is that redux/zustand/... could also serve as storage for the promise based data however as lower level storage. TanStack Query brings another tools like caching or state tracking (isSuccess/isLoading/...) but with a tradeoff. There are use cases where TanStack Query does not fit and these situations need to be solved ad hoc however e.g. redux still can help holding the data.
Great article, switching to TankStack Query, thanks!
Thanks! Glad you found it helpful.
Tanstack query uses useEffect internally though...
True, it uses useEffect internally, but that’s the point.
It abstracts all the tricky parts like caching, retries, and race conditions so you don’t have to deal with them in every component.
The goal isn’t to avoid useEffect, but to use it in a safer, consistent way.
That's a big assumption that tanstack query uses useEffect in a safe way 😛