Stop Using useEffect for Data Fetching
Why manual data fetching in useEffect gets fragile, and how TanStack Query gives React apps a cleaner async-state model.
A few weeks ago, the React community was buzzing after the useEffect debacle at Cloudflare. The big takeaway for me was simple: we have been leaning on useEffect for data fetching in places where it makes the application harder to reason about.
Yes, useEffect can fetch data. The problem is everything around the fetch: loading state, error state, cleanup, race conditions, dependency arrays, retries, and caching. That is a lot of manual machinery for something most React apps do every day.
That is where TanStack Query, formerly React Query, fits well. It gives you a declarative model for async data with loading, errors, caching, retries, and background refetching handled for you.
What Happened at Cloudflare?
Cloudflare described a bug where an object inside a dependency array was recreated on render. React saw that object as new, so the effect ran again and again, creating much more API traffic than intended.
That is the exact class of bug that makes data fetching in effects risky. The fetch logic is tied to render behavior, dependency identity, and cleanup discipline. One small mistake can become a very expensive loop.
TanStack Query avoids this by moving the identity of a request into a stable query key instead of an effect dependency array that you have to maintain by hand.
The Traditional Way: useEffect
Here is a typical profile fetch implemented with an effect.
import { useEffect, useState } from "react";
type GitHubUser = {
name: string;
bio: string;
};
async function fetchUser(): Promise<GitHubUser> {
const response = await fetch("https://api.github.com/users/deveshsangwan");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
}
export function UserProfile() {
const [user, setUser] = useState<GitHubUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
async function getUser() {
try {
const userData = await fetchUser();
if (isMounted) {
setUser(userData);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : "Something went wrong");
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
}
getUser();
return () => {
isMounted = false;
};
}, []);
if (isLoading) return <div>Loading profile...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}It works, but notice how much responsibility the component is carrying.
- Manual loading, error, and data state.
- Cleanup logic to avoid updating state after unmount.
- Repeated boilerplate across every component that fetches data.
- No built-in caching, retrying, or background refetching.
The TanStack Query Way
First, set up a query client at the root of the app.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { UserProfile } from "./user-profile";
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<UserProfile />
</QueryClientProvider>
);
}Then the component becomes much smaller and more declarative.
import { useQuery } from "@tanstack/react-query";
type GitHubUser = {
name: string;
bio: string;
};
async function fetchUser(): Promise<GitHubUser> {
const response = await fetch("https://api.github.com/users/deveshsangwan");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
}
export 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>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}The important part is that the request gets a stable identity through queryKey. In this case,["user", "deveshsangwan"] tells TanStack Query exactly what data this request represents.
The queryFn is the async function that performs the work. It returns data or throws an error, and TanStack Query handles the request lifecycle around it.
Why This Feels Better
- No effect dependency array for request identity.
- No hand-rolled loading and error lifecycle.
- Built-in caching and background refetching.
- Cleaner components that focus on rendering UI.
The result is not just fewer lines of code. It is fewer places for subtle bugs to hide, especially as the app grows and multiple screens start asking for the same data.
Final Thoughts
useEffect is still useful. It is just not the best default tool for server data. When data can be cached, become stale, fail, retry, or be shared across screens, a dedicated async-state tool is usually a better fit.
TanStack Query lets you spend more time on the UI and product behavior, and less time rebuilding the same request lifecycle in every component.