You fetch data with useEffect. You manage loading, error, and success states manually. You cache nothing — every page navigation re-fetches everything. Users see loading spinners constantly.
What if your data fetching library handled caching, deduplication, background updates, pagination, and optimistic updates — automatically?
That's TanStack Query (formerly React Query).
Before and After
Without TanStack Query
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch("/api/users")
.then(r => r.json())
.then(data => { if (!cancelled) { setUsers(data); setLoading(false); } })
.catch(err => { if (!cancelled) { setError(err); setLoading(false); } });
return () => { cancelled = true; };
}, []);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
With TanStack Query
function UserList() {
const { data: users, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then(r => r.json()),
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
But the real difference isn't code length — it's behavior:
- Automatic caching — navigate away and back, data shows instantly
- Background refetching — stale data shows while fresh data loads
- Deduplication — 5 components request "users" = 1 API call
- Window focus refetching — user tabs back, data refreshes automatically
Setup
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // Data fresh for 1 minute
gcTime: 300_000, // Cache kept for 5 minutes
retry: 3, // Retry failed requests 3 times
refetchOnWindowFocus: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
</QueryClientProvider>
);
}
Mutations With Optimistic Updates
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const toggleMutation = useMutation({
mutationFn: (id: string) =>
fetch(`/api/todos/${id}/toggle`, { method: "PATCH" }).then(r => r.json()),
// Optimistic update — instant UI feedback
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old: Todo[]) =>
old.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
return { previous };
},
// Rollback on error
onError: (err, id, context) => {
queryClient.setQueryData(["todos"], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<li onClick={() => toggleMutation.mutate(todo.id)}>
{todo.done ? "✓" : "○"} {todo.text}
</li>
);
}
Infinite Scroll
function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["feed"],
queryFn: ({ pageParam }) =>
fetch(`/api/feed?cursor=${pageParam}`).then(r => r.json()),
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
return (
<div>
{data?.pages.flatMap(page => page.items).map(item => (
<FeedItem key={item.id} item={item} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}
Dependent Queries
function UserProfile({ userId }) {
const userQuery = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
const postsQuery = useQuery({
queryKey: ["posts", userId],
queryFn: () => fetchUserPosts(userId),
enabled: !!userQuery.data, // Only runs after user loads
});
return (
<div>
<h1>{userQuery.data?.name}</h1>
{postsQuery.data?.map(post => <PostCard key={post.id} post={post} />)}
</div>
);
}
When to Choose TanStack Query
Choose TanStack Query when:
- Your app fetches server data (any app with API calls)
- You want automatic caching and background updates
- Pagination or infinite scroll is needed
- You want to eliminate manual loading/error state management
Skip TanStack Query when:
- Client-only state (use Zustand/Jotai instead)
- Very simple app with 1-2 fetch calls (overkill)
- You use a framework with built-in data layer (Remix loaders, SvelteKit load)
The Bottom Line
TanStack Query turns server state from a problem you solve to a problem that's solved. Caching, deduplication, background updates, optimistic mutations — you get production-grade data fetching without writing the infrastructure.
Start here: tanstack.com/query
Need custom data extraction, scraping, or automation? I build tools that collect and process data at scale — 78 actors on Apify Store and 265+ open-source repos. Email me: Spinov001@gmail.com | My Apify Actors
Top comments (0)