React7 min read
TanStack Query Guide: React Data Fetching Done Right
A practical TanStack Query (React Query v5) guide: query keys, caching with staleTime and gcTime, mutations, optimistic updates, pagination and infinite scroll.
TanStack Query (formerly React Query) is a server-state library for React: you give it a query key and an async query function, and it handles caching, deduplication, background refetching, retries, pagination and mutations for you. In version 5, the current major, everything uses a single object argument (useQuery({ queryKey, queryFn })), and the first-load flag is isPending. This guide covers the patterns I use in production, with TypeScript examples.
Key takeaways
- Query keys are the cache. Treat them like dependency arrays: everything the query function uses goes in the key.
staleTime(default 0) decides when to refetch.gcTime(default 5 minutes) decides when to forget unused data.- Use
queryOptions()to define keys and fetchers once and reuse them in hooks, prefetching and invalidation. - After a mutation, invalidate the related queries. For instant UI, add optimistic updates with rollback.
- Use
placeholderData: keepPreviousDatafor paginated tables anduseInfiniteQueryfor "load more" and infinite scroll. fetchdoes not throw on 4xx or 5xx responses. Throw yourself, or TanStack Query treats errors as success.
Setup: QueryClient and provider
npm install @tanstack/react-query @tanstack/react-query-devtoolsimport { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState, type ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
// useState so the client is created once per app instance (important with SSR)
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000 },
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}The devtools are excluded from production builds by default. Keep them open while learning: watching queries go from fresh to stale to inactive explains caching faster than any article.
Your first query, and isPending vs isFetching
import { useQuery } from "@tanstack/react-query";
type Post = { id: number; title: string; body: string };
async function getPosts(): Promise<Post[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!res.ok) throw new Error(`Failed to load posts (${res.status})`);
return res.json();
}
export function Posts() {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ["posts"],
queryFn: getPosts,
});
if (isPending) return <p>Loading posts...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<>
{isFetching && <small>Refreshing...</small>}
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</>
);
}| Flag | True when | Typical UI |
|---|---|---|
isPending |
No cached data yet | Skeleton or spinner |
isFetching |
Any request is running, including background refetches | Small "refreshing" indicator |
isLoading |
isPending && isFetching (first load in progress) |
Same as isPending, but false if the query is disabled |
isError |
The last fetch failed after retries | Error message with retry button |
isSuccess |
Data is available | Render data |
After the isPending and isError checks, TypeScript narrows data to Post[], so no data?. is needed.
Query keys and queryOptions
The query key identifies the cached data. If your function depends on a variable, that variable must be in the key, or you will see data for the wrong ID.
import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query";
type Post = { id: number; title: string; body: string };
async function getPost(id: number): Promise<Post> {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
if (!res.ok) throw new Error(`Failed to load post ${id}`);
return res.json();
}
export const postQueries = {
all: () => ["posts"] as const,
detail: (id: number) =>
queryOptions({
queryKey: [...postQueries.all(), id],
queryFn: () => getPost(id),
staleTime: 5 * 60_000,
}),
};
export function PostDetail({ id }: { id: number }) {
const { data, isPending } = useQuery(postQueries.detail(id));
if (isPending) return <p>Loading...</p>;
return <h1>{data?.title}</h1>;
}
export function PostLink({ id, title }: { id: number; title: string }) {
const queryClient = useQueryClient();
return (
<a
href={`/posts/${id}`}
onMouseEnter={() => queryClient.prefetchQuery(postQueries.detail(id))}
>
{title}
</a>
);
}queryOptions gives you one typed definition you can pass to useQuery, useSuspenseQuery, prefetchQuery and getQueryData, and the key carries the data type with it. Structure keys from general to specific (["posts"], then ["posts", 42]), because invalidation matches by prefix: invalidating ["posts"] also marks every post detail as stale.
Caching: staleTime vs gcTime
These two options confuse almost everyone at first.
staleTimeis how long data is considered fresh. While fresh, mounting a component or refocusing the window will not refetch. The default is0, so data is stale immediately and refetches on mount, window focus and reconnect.gcTime(renamed fromcacheTimein v5) is how long inactive data stays in memory after the last observer unmounts. The default is 5 minutes.
A good starting point: set a global staleTime of somewhere between 30 seconds and a few minutes for typical dashboard data, then override per query. Data that rarely changes (a country list, app settings) can use staleTime: Infinity. Live data like scores can poll with refetchInterval.
Mutations and cache invalidation
useMutation handles writes. After a successful write, invalidate the queries that are now out of date.
import { useMutation, useQueryClient } from "@tanstack/react-query";
type NewPost = { title: string; body: string };
async function createPost(input: NewPost) {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Could not create post");
return (await res.json()) as NewPost & { id: number };
}
export function NewPostButton() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createPost,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
return (
<>
<button
disabled={mutation.isPending}
onClick={() => mutation.mutate({ title: "Hello", body: "First post" })}
>
{mutation.isPending ? "Saving..." : "Create post"}
</button>
{mutation.isError && <p role="alert">{mutation.error.message}</p>}
</>
);
}Returning (or awaiting) the invalidateQueries promise keeps the mutation in its pending state until the refetch finishes, so the button does not flip back before the list updates.
Optimistic updates with rollback
Optimistic updates show the change immediately and roll back if the server rejects it. The cache-based version:
import { useMutation, useQueryClient } from "@tanstack/react-query";
type Todo = { id: number; title: string; done: boolean };
async function toggleTodo(todo: Todo): Promise<Todo> {
const res = await fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ done: !todo.done }),
});
if (!res.ok) throw new Error("Update failed");
return res.json();
}
export function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: toggleTodo,
onMutate: async (todo) => {
// 1. Stop in-flight refetches from overwriting the optimistic value
await queryClient.cancelQueries({ queryKey: ["todos"] });
// 2. Snapshot the current value
const previous = queryClient.getQueryData<Todo[]>(["todos"]);
// 3. Write the optimistic value
queryClient.setQueryData<Todo[]>(["todos"], (old = []) =>
old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)),
);
// 4. Return the snapshot for rollback
return { previous };
},
onError: (_error, _todo, onMutateResult) => {
if (onMutateResult?.previous) {
queryClient.setQueryData(["todos"], onMutateResult.previous);
}
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
}The value returned from onMutate arrives as the third argument of onError and the fourth of onSettled. Recent v5 releases also pass a final context argument that exposes context.client, the QueryClient, but using useQueryClient() as above works across all v5 versions.
For simpler cases, skip the cache writes and render from the mutation's variables while isPending is true. The official optimistic updates guide shows both approaches. If you are using React 19 Actions instead, useOptimistic is the built-in alternative (see React 19 hooks).
Pagination with keepPreviousData
For a numbered table, put the page in the key and keep showing the previous page while the next one loads, instead of flashing a spinner.
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useState } from "react";
type Page<T> = { items: T[]; hasMore: boolean };
type User = { id: number; name: string };
async function getUsers(page: number): Promise<Page<User>> {
const res = await fetch(`/api/users?page=${page}`);
if (!res.ok) throw new Error("Failed to load users");
return res.json();
}
export function UsersTable() {
const [page, setPage] = useState(1);
const { data, isPending, isPlaceholderData } = useQuery({
queryKey: ["users", page],
queryFn: () => getUsers(page),
placeholderData: keepPreviousData,
});
if (isPending) return <p>Loading...</p>;
return (
<>
<ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.items.map((u) => <li key={u.id}>{u.name}</li>)}
</ul>
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
Previous
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={isPlaceholderData || !data?.hasMore}
>
Next
</button>
</>
);
}In v5, keepPreviousData: true became placeholderData: keepPreviousData, and isPreviousData became isPlaceholderData.
Infinite queries for "load more"
import { useInfiniteQuery } from "@tanstack/react-query";
type FeedPage = { items: { id: string; text: string }[]; nextCursor: string | null };
async function getFeed(cursor: string | null): Promise<FeedPage> {
const res = await fetch(`/api/feed${cursor ? `?cursor=${cursor}` : ""}`);
if (!res.ok) throw new Error("Failed to load feed");
return res.json();
}
export function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
useInfiniteQuery({
queryKey: ["feed"],
queryFn: ({ pageParam }) => getFeed(pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
if (isPending) return <p>Loading...</p>;
return (
<>
{data?.pages.flatMap((page) => page.items).map((item) => (
<p key={item.id}>{item.text}</p>
))}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? "Loading more..." : hasNextPage ? "Load more" : "No more posts"}
</button>
</>
);
}initialPageParam is required in v5. Returning null or undefined from getNextPageParam sets hasNextPage to false. For very long feeds, the maxPages option limits how many pages stay in memory, and pairing this with list virtualization (covered in the React performance checklist) keeps the DOM small.
Common TanStack Query mistakes
- Not throwing on HTTP errors.
fetchresolves on 404 and 500. Checkres.okand throw. - Missing variables in the query key. Two different users end up sharing one cache entry.
- Copying query data into
useState. You lose background updates. Read from the query directly, and useselectto derive data. - Creating
new QueryClient()inside a component body withoutuseState. Every render gets a fresh, empty cache. - Using
onSuccessonuseQuery. It was removed from queries in v5. Derive values during render, or use an effect only if you truly sync with something outside React. - Leaving
staleTimeat 0 everywhere. That is fine for correctness but causes more refetching than most apps need. - One giant query for a whole page. Split by resource so each part caches, refetches and invalidates on its own schedule.
FAQ
What is the difference between staleTime and gcTime in TanStack Query?
staleTime controls how long data counts as fresh, so no background refetch happens during that window (default 0). gcTime controls how long unused data stays in the cache after the last component using it unmounts (default 5 minutes).
What is the difference between isPending, isLoading and isFetching?
isPending means there is no data yet. isFetching is true whenever a request is in flight, including background refetches. isLoading is isPending and isFetching together, meaning the first load is happening right now.
Is TanStack Query the same as React Query?
Yes. React Query was renamed TanStack Query when it became framework-agnostic. The React package is @tanstack/react-query, and version 5 is the current major version.
Do I still need TanStack Query with React Server Components?
Often less, but still useful. Server Components handle initial data well, while TanStack Query shines for client-side interactivity: mutations, optimistic updates, polling, infinite scroll and data shared across many client components.
Wrapping up
TanStack Query turns server data into a cache you configure instead of code you maintain. Get the query keys right, pick a sensible staleTime, invalidate after mutations, and add optimistic updates where speed matters. If you are deciding how it fits with Zustand or Redux, read React state management in 2026.
- #react
- #tanstack-query
- #data-fetching
- #caching
Keep reading
React9 min read
React State Management in 2026: Zustand vs Redux vs Context
React state management in 2026: when to use Context, Zustand, Redux Toolkit or TanStack Query, and why splitting server state from client state matters most.
Read articleReact7 min read
10 Custom React Hooks Worth Having (TypeScript Examples)
10 custom React hooks in TypeScript: useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside, useInterval and more, built for React 19 and the compiler.
Read articleReact8 min read
React 19 Hooks: use, useActionState, useOptimistic Explained
React 19 hooks explained with TypeScript examples: use, useActionState, useOptimistic and useFormStatus, plus the gotchas that trip people up in real forms.
Read article