TanStack Query — Server State Management Done Right
How TanStack Query handles caching, refetching, pagination, and optimistic updates — and why it replaces most of your Redux/Zustand server state logic.
Most React apps have two kinds of state: client state (modal open/closed, form inputs, UI preferences) and server state (user data, posts, comments, anything from an API). The mistake the industry made for years was using the same tool for both. Redux stores filled with API responses. useEffect + useState spaghetti for every fetch. Manual loading states, error states, refetch logic, cache invalidation, stale data handling — all reimplemented in every component.
TanStack Query (formerly React Query) exists because server state is fundamentally different from client state. Server state is asynchronous, shared across components, can become stale, and needs background updates. TanStack Query handles all of this with a declarative API that eliminates most of the data-fetching code you'd write by hand.
The Problem It Solves
Here's the typical "fetch data in React" pattern without TanStack Query:
// The painful way — every component that fetches data looks like this
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(/api/users/${userId})
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
})
.then((data) => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;
return <div>{user.name}</div>;
}
That's 30 lines for a single fetch. No caching. No refetching. No deduplication. If two components fetch the same user, you get two network requests.
The TanStack Query Way
bun add @tanstack/react-query
// Wrap your app
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
// Same component — 8 lines instead of 30
import { useQuery } from "@tanstack/react-query";
function UserProfile({ userId }: { userId: number }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(/api/users/${userId}).then((r) => r.json()),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user.name}</div>;
}
What you get for free:
- Caching — the result is cached by
queryKey. Navigate away and back, no re-fetch. - Deduplication — three components using
["user", 1]trigger one network request. - Background refetching — data is refreshed when the window regains focus or the component remounts.
- Stale-while-revalidate — shows cached data immediately while fetching fresh data in the background.
- Retry — failed requests are retried 3 times by default.
- Garbage collection — unused cache entries are cleaned up after 5 minutes.
Query Keys
Query keys are the cache keys. They determine when data is shared between components and when it's refetched.
// Simple key
useQuery({ queryKey: ["todos"], queryFn: fetchTodos });
// Key with parameters — different userId = different cache entry
useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId) });
// Complex keys — filters, pagination
useQuery({
queryKey: ["posts", { status: "published", page: 2, author: "alice" }],
queryFn: () => fetchPosts({ status: "published", page: 2, author: "alice" }),
});
// Keys are compared structurally, not by reference
// ["posts", { page: 1 }] and ["posts", { page: 1 }] share the same cache
Mutations
Queries are for reading data. Mutations are for creating, updating, and deleting.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function CreateTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo: { title: string }) =>
fetch("/api/todos", {
method: "POST",
body: JSON.stringify(newTodo),
headers: { "Content-Type": "application/json" },
}).then((r) => r.json()),
onSuccess: () => {
// Invalidate the todos cache — triggers a refetch
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
const title = new FormData(e.currentTarget).get("title") as string;
mutation.mutate({ title });
}}>
<input name="title" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Adding..." : "Add Todo"}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
Optimistic Updates
Show the change immediately, then reconcile with the server response:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Cancel in-flight refetches
await queryClient.cancelQueries({ queryKey: ["todos"] });
// Snapshot previous value
const previous = queryClient.getQueryData(["todos"]);
// Optimistically update cache
queryClient.setQueryData(["todos"], (old: Todo[]) =>
old.map((todo) =>
todo.id === updatedTodo.id ? { ...todo, ...updatedTodo } : todo
)
);
return { previous };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(["todos"], context?.previous);
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
Pagination and Infinite Queries
// Standard pagination
function PaginatedPosts() {
const [page, setPage] = useState(1);
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ["posts", page],
queryFn: () => fetchPosts(page),
placeholderData: (previousData) => previousData, // Keep previous data while loading next page
});
return (
<div>
{data?.posts.map((post) => <PostCard key={post.id} post={post} />)}
<div className="flex gap-2">
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!data?.hasMore}
style={{ opacity: isPlaceholderData ? 0.5 : 1 }}
>
Next
</button>
</div>
</div>
);
}
// Infinite scroll
import { useInfiniteQuery } from "@tanstack/react-query";
function InfinitePostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, limit: 20 }),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<div>
{allPosts.map((post) => <PostCard key={post.id} post={post} />)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Loading more..." : "Load More"}
</button>
)}
</div>
);
}
Configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 60 5, // Data is fresh for 5 minutes
gcTime: 1000 60 30, // Cache garbage collected after 30 minutes
retry: 2, // Retry failed requests twice
refetchOnWindowFocus: true, // Refetch when tab regains focus
refetchOnReconnect: true, // Refetch when network reconnects
refetchOnMount: true, // Refetch when component mounts
},
mutations: {
retry: 0, // Don't retry failed mutations
},
},
});
Stale time is the most important setting. If your data changes rarely (user profile), set staleTime high. If it changes constantly (chat messages), keep it low or use websockets.
When to Use TanStack Query vs Alternatives
| Scenario | Recommendation |
|---|---|
| Fetching API data in React | TanStack Query |
| Global UI state (theme, sidebar) | Zustand or Jotai |
| Complex client-only state machines | XState or Zustand |
| Form state | React Hook Form |
| Real-time data (websockets) | TanStack Query + websocket integration |
| Server-side rendering | TanStack Query (has SSR support) |
| GraphQL | TanStack Query or urql |
- Client-only state (modals, form inputs, local preferences)
- State that never touches a server
- Simple apps with one or two API calls (might be overkill)