Skip to content

TanStack Query v5 cheatsheet for React from fetching and caching to mutations, optimistic updates, pagination, prefetching, and production patterns.

TanStack Query, commonly called React Query in React applications, manages server state: asynchronous data fetched from APIs that can become stale, be shared between screens, and require refetching or updating.

This page uses the TanStack Query v5 React API.

ProblemTanStack Query Tool
Fetch and cache datauseQuery
Change server datauseMutation
Refresh data after a changequeryClient.invalidateQueries
Fetch before rendering a screenprefetchQuery / ensureQueryData
Paginated or infinite listsuseQuery / useInfiniteQuery
Inspect request cache in developmentReact Query Devtools

TanStack Query is not a replacement for every kind of React state. Keep local form fields, modal visibility, and UI toggles in React state unless they represent data owned by the server.


Terminal window
npm install @tanstack/react-query

Optional development tools:

Terminal window
npm install @tanstack/react-query-devtools
npm install -D @tanstack/eslint-plugin-query

Create one QueryClient for the application and provide it above components that call query hooks:

src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { App } from "./App";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>,
);
ItemMeaning
QueryClientHolds cache, defaults, and imperative cache methods
QueryClientProviderMakes the client available to React query hooks
DevtoolsDisplays cached queries, freshness, observers, and actions

Create the client outside component rendering. Creating it inside App would produce a new cache when App is recreated.


A query needs a unique key and a function that returns a promise.

import { useQuery } from "@tanstack/react-query";
type Todo = {
id: number;
title: string;
completed: boolean;
};
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch("/api/todos");
if (!response.ok) {
throw new Error("Failed to load todos");
}
return response.json();
}
export function TodoList() {
const todosQuery = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
if (todosQuery.isPending) return <p>Loading todos...</p>;
if (todosQuery.isError) return <p role="alert">{todosQuery.error.message}</p>;
return (
<ul>
{todosQuery.data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
PropertyMeaning
dataSuccessful response data, or undefined before success
errorError thrown by queryFn after final failure
isPendingNo successfully cached data is available yet
isErrorThe query reached an error state
isSuccessData is available successfully
isFetchingA request is in flight, including background refresh
refetch()Manually request this query again

Show a small refreshing indicator without removing cached content:

return (
<>
{todosQuery.isFetching && <p>Refreshing...</p>}
<TodoRows todos={todosQuery.data} />
</>
);

Query keys identify cached data. Include every value that changes what the request returns.

useQuery({
queryKey: ["todos", { status, page }],
queryFn: () => fetchTodos({ status, page }),
});
useQuery({
queryKey: ["users", userId],
queryFn: () => fetchUser(userId),
});
RequestGood Query Key
All todos["todos"]
One todo["todos", todoId]
Filtered todos["todos", { status: "done" }]
Search page["products", { query, page, sort }]

Use a consistent key factory when many features read or invalidate the same data:

export const todoKeys = {
all: ["todos"] as const,
lists: () => [...todoKeys.all, "list"] as const,
list: (filters: { status?: string }) =>
[...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, "detail"] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
useQuery({
queryKey: todoKeys.detail(todoId),
queryFn: () => fetchTodo(todoId),
});

Keys should be serializable and stable. Do not include newly created class instances, DOM nodes, or values unrelated to the response.


4. Shared Query Definitions with queryOptions

Section titled “4. Shared Query Definitions with queryOptions”

Co-locate a key and query function so components, routes, and prefetching use the same definition.

import { queryOptions, useQuery } from "@tanstack/react-query";
type Product = {
id: string;
name: string;
price: number;
};
async function fetchProduct(productId: string): Promise<Product> {
const response = await fetch(`/api/products/${productId}`);
if (!response.ok) throw new Error("Product not found");
return response.json();
}
export function productQueryOptions(productId: string) {
return queryOptions({
queryKey: ["products", productId],
queryFn: () => fetchProduct(productId),
staleTime: 60_000,
});
}
function ProductPage({ productId }: { productId: string }) {
const productQuery = useQuery(productQueryOptions(productId));
if (productQuery.isPending) return <p>Loading...</p>;
if (productQuery.isError) return <p>Could not load product.</p>;
return <h1>{productQuery.data.name}</h1>;
}

The same options can be reused:

queryClient.prefetchQuery(productQueryOptions(productId));
queryClient.ensureQueryData(productQueryOptions(productId));

Understanding defaults prevents surprising refetches.

Default BehaviorWhat It Means
Cached query data is stale immediatelyA mounted/refocused query may refresh in the background
Stale active queries may refetch on mount, focus, or reconnectUsers see current data when returning to the app
Inactive query data stays cached for 5 minutesReturning soon can reuse previous data
Failed client-side queries retry 3 timesTemporary failures may recover automatically
Results use structural sharingUnchanged JSON-compatible data can keep stable references

Configure global defaults when your API behavior is consistent:

const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
useQuery({
queryKey: ["countries"],
queryFn: fetchCountries,
staleTime: 10 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});
OptionControls
staleTimeHow long data is considered fresh and avoids staleness-triggered refetches
gcTimeHow long unused/inactive cached data remains before removal
refetchIntervalPolling interval while the query is active
retryRequest retries after failure

Choose staleTime based on how quickly the server data changes, not how quickly the component renders.


Only fetch when required input exists:

function UserProjects({ userId }: { userId: string | undefined }) {
const projectsQuery = useQuery({
queryKey: ["projects", userId],
queryFn: () => fetchProjects(userId!),
enabled: Boolean(userId),
});
if (!userId) return <p>Select a user.</p>;
if (projectsQuery.isPending) return <p>Loading projects...</p>;
return <ProjectList projects={projectsQuery.data} />;
}

Load a second request from the first request’s result:

const userQuery = useQuery({
queryKey: ["user", email],
queryFn: () => fetchUserByEmail(email),
});
const projectsQuery = useQuery({
queryKey: ["projects", userQuery.data?.id],
queryFn: () => fetchProjects(userQuery.data!.id),
enabled: Boolean(userQuery.data?.id),
});

Do not call useQuery conditionally. Always call the hook and use enabled to control execution.


Use select to provide a component only the data shape it needs:

function CompletedCount() {
const countQuery = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
select: (todos) => todos.filter((todo) => todo.completed).length,
});
if (countQuery.isPending) return null;
return <p>Completed: {countQuery.data}</p>;
}
Good Use for selectAvoid
Pick a field or derived count for displayMutating cached objects
Limit what rerenders this component needsTreating it as a replacement for server processing

The underlying cache still stores the query function result; select transforms what this observer receives.


A mutation performs a server change. Invalidate affected queries after success so displayed data is refreshed.

import { useMutation, useQueryClient } from "@tanstack/react-query";
type NewTodo = {
title: string;
};
async function createTodo(newTodo: NewTodo) {
const response = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTodo),
});
if (!response.ok) throw new Error("Failed to create todo");
return response.json();
}
export function AddTodoButton() {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
return queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<>
<button
type="button"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate({ title: "Read docs" })}
>
{createMutation.isPending ? "Adding..." : "Add todo"}
</button>
{createMutation.isError && (
<p role="alert">{createMutation.error.message}</p>
)}
</>
);
}
Property or OptionUse
mutationFnFunction that performs the server change
mutate(variables)Start mutation with callback-style handling
mutateAsync(variables)Start mutation and await a promise
isPendingDisable duplicate submit or show progress
isError / errorDisplay failure feedback
onSuccessUpdate or invalidate data after success
onSettledRun after either success or failure
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
await createMutation.mutateAsync({ title });
setTitle("");
} catch {
// The mutation error state can render the message.
}
}

queryClient.invalidateQueries({ queryKey: ["todos"] });
queryClient.invalidateQueries({ queryKey: ["todos", "list"] });

Prefix matching means invalidating ["todos"] can affect detail and list queries beginning with that key.

If the API returns the updated object, put it directly into its detail cache:

const updateMutation = useMutation({
mutationFn: updateTodo,
onSuccess: (updatedTodo) => {
queryClient.setQueryData(["todos", updatedTodo.id], updatedTodo);
return queryClient.invalidateQueries({ queryKey: ["todos", "list"] });
},
});
const cachedTodo = queryClient.getQueryData<Todo>(["todos", todoId]);
MethodUse It For
invalidateQueriesMark related data stale and refresh active views
setQueryDataSynchronously update a known cache entry
getQueryDataRead current cached data without fetching
removeQueriesRemove cached data, such as after logout

Do not mutate objects returned from the cache. Provide new arrays or objects to setQueryData.


Optimistic UI shows the expected result before the server response arrives. Always preserve a rollback path.

const toggleMutation = useMutation({
mutationFn: toggleTodo,
onMutate: async (todoId: number) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);
queryClient.setQueryData<Todo[]>(["todos"], (current = []) =>
current.map((todo) =>
todo.id === todoId
? { ...todo, completed: !todo.completed }
: todo,
),
);
return { previousTodos };
},
onError: (_error, _todoId, context) => {
queryClient.setQueryData(["todos"], context?.previousTodos);
},
onSettled: () => {
return queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
  1. Cancel an outgoing refetch that could overwrite optimistic state.
  2. Save the previous cached value.
  3. Write the optimistic value.
  4. Roll back when the mutation fails.
  5. Invalidate after completion to agree with the server.

For simple “add item” experiences, it may be enough to display the pending mutation variables in the UI instead of editing the cache.


Include the page in the query key. Preserve previous page data while loading the next page:

import { keepPreviousData, useQuery } from "@tanstack/react-query";
function Products() {
const [page, setPage] = useState(1);
const productsQuery = useQuery({
queryKey: ["products", { page }],
queryFn: () => fetchProducts(page),
placeholderData: keepPreviousData,
});
if (productsQuery.isPending) return <p>Loading products...</p>;
return (
<>
<ProductList products={productsQuery.data.items} />
{productsQuery.isPlaceholderData && <p>Loading next page...</p>}
<button disabled={page === 1} onClick={() => setPage((value) => value - 1)}>
Previous
</button>
<button
disabled={!productsQuery.data.hasMore}
onClick={() => setPage((value) => value + 1)}
>
Next
</button>
</>
);
}
OptionBehavior
placeholderData: keepPreviousDataKeeps the prior page visible during a key change
isPlaceholderDataIdentifies that displayed data is temporary
initialDataPlaces complete initial data into the cache

Use placeholderData for incomplete preview/transition data. initialData is stored as real cached query data.


Use useInfiniteQuery for “load more” or infinite scrolling lists.

import { useInfiniteQuery } from "@tanstack/react-query";
type ProductPage = {
items: Product[];
nextCursor?: string;
};
async function fetchProductPage(cursor: string | null): Promise<ProductPage> {
const url = new URL("/api/products", window.location.origin);
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to load products");
return response.json();
}
function InfiniteProducts() {
const productsQuery = useInfiniteQuery({
queryKey: ["products", "infinite"],
queryFn: ({ pageParam }) => fetchProductPage(pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
if (productsQuery.isPending) return <p>Loading...</p>;
const products = productsQuery.data.pages.flatMap((page) => page.items);
return (
<>
<ProductList products={products} />
<button
type="button"
disabled={!productsQuery.hasNextPage || productsQuery.isFetchingNextPage}
onClick={() => productsQuery.fetchNextPage()}
>
{productsQuery.isFetchingNextPage ? "Loading..." : "Load more"}
</button>
</>
);
}
Infinite Query ValueMeaning
data.pagesArray of fetched response pages
data.pageParamsParameters used for each page
fetchNextPage()Request the next page
hasNextPageWhether getNextPageParam returned another parameter
isFetchingNextPageOnly the next-page request is loading

Prefetch likely-needed data before navigation or expansion:

function ProductLink({ productId }: { productId: string }) {
const queryClient = useQueryClient();
function prefetchProduct() {
queryClient.prefetchQuery(productQueryOptions(productId));
}
return (
<a href={`/products/${productId}`} onMouseEnter={prefetchProduct}>
View product
</a>
);
}

For loaders or workflows that require data before continuing:

const product = await queryClient.ensureQueryData(
productQueryOptions(productId),
);
MethodDifference
prefetchQueryFills cache ahead of time; does not return query data
ensureQueryDataReturns cached data or fetches it if absent
fetchQueryFetches according to options and returns data

Prefetch interactions users are likely to perform; avoid downloading entire applications speculatively.


Hooks at the same component level execute in parallel:

const profileQuery = useQuery({
queryKey: ["profile", userId],
queryFn: () => fetchProfile(userId),
});
const notificationsQuery = useQuery({
queryKey: ["notifications", userId],
queryFn: () => fetchNotifications(userId),
});

For a changing number of queries, use useQueries:

import { useQueries } from "@tanstack/react-query";
const productQueries = useQueries({
queries: productIds.map((productId) => productQueryOptions(productId)),
});

Avoid unnecessary waterfalls: if two requests do not depend on each other, start them together.


Applications using React Suspense can opt into suspense-oriented hooks:

import { useSuspenseQuery } from "@tanstack/react-query";
function ProductDetails({ productId }: { productId: string }) {
const { data: product } = useSuspenseQuery(productQueryOptions(productId));
return <h1>{product.name}</h1>;
}
<ErrorBoundary fallback={<p>Unable to show product.</p>}>
<Suspense fallback={<p>Loading product...</p>}>
<ProductDetails productId={productId} />
</Suspense>
</ErrorBoundary>

Choose either explicit isPending/isError rendering or a consistent Suspense/error-boundary architecture for a section of the app.


Keep HTTP details outside components and throw for failed responses:

type ApiErrorBody = {
message?: string;
};
export async function requestJson<T>(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
throw new Error(body.message ?? `Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
const usersQuery = useQuery({
queryKey: ["users"],
queryFn: () => requestJson<User[]>("/api/users"),
});

TanStack Query determines query errors when the query function rejects or throws; fetch() alone does not throw for HTTP error status codes.


Provide the QueryClient through router context and let route loaders ensure cached data exists before rendering:

// router context idea
type RouterContext = {
queryClient: QueryClient;
};
export const Route = createFileRoute("/products/$productId")({
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(
productQueryOptions(params.productId),
),
component: ProductRoute,
});
function ProductRoute() {
const { productId } = Route.useParams();
const { data: product } = useQuery(productQueryOptions(productId));
return <h1>{product.name}</h1>;
}

The router knows what screen will be visited; Query manages shared cached server data. Together they can avoid loading waterfalls and duplicate request definitions.


SituationPattern
User logs outRemove sensitive user-specific cached queries
API data changes rarelyIncrease staleTime
Filter belongs in URLStore filter in router search params; include it in query key
Write changes a list and detailUpdate detail cache and invalidate matching lists
Several screens share dataReuse queryOptions and key factories
Request depends on auth tokenKeep token/request client in API layer or app context
await logout();
queryClient.removeQueries({ queryKey: ["viewer"] });
queryClient.removeQueries({ queryKey: ["account"] });
useQuery({
queryKey: ["jobs", jobId],
queryFn: () => fetchJob(jobId),
refetchInterval: (query) =>
query.state.data?.status === "finished" ? false : 2_000,
});

Mistake or SymptomCauseFix
Different filters show same cached dataFilter missing from query keyPut every request input in the key
404 is treated like successful datafetch() did not throwCheck response.ok and throw
Refetching seems frequentDefault data becomes stale immediatelySet an appropriate staleTime
Form fields stored in Query cacheServer and local UI state mixed togetherKeep unsaved fields in local/form state
POST succeeds but UI stays oldRelated queries were not updatedInvalidate or set cached data on success
Optimistic change remains after failureNo rollback storedReturn previous state from onMutate
Component cannot use hooks inside a callbackHook rules violatedCall hooks at component top level
Partial preview pollutes detail cacheUsed initialData for incomplete dataUse placeholderData

const query = useQuery({
queryKey: ["resource", id],
queryFn: () => fetchResource(id),
});
const query = useQuery({
queryKey: ["countries"],
queryFn: fetchCountries,
staleTime: 10 * 60 * 1000,
});
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: saveItem,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["items"] }),
});
await queryClient.prefetchQuery({
queryKey: ["item", id],
queryFn: () => fetchItem(id),
});
useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId!),
enabled: Boolean(userId),
});

  1. Wrap the app in QueryClientProvider.
  2. Fetch a list with useQuery and render loading/error/success states.
  3. Learn query keys and include request parameters in them.
  4. Create data with useMutation and invalidate the list afterward.
  1. Reuse definitions with queryOptions and key factories.
  2. Configure staleTime, retries, and refetch behavior deliberately.
  3. Build pagination and infinite loading experiences.
  4. Prefetch likely destination data and inspect the cache with Devtools.
  1. Implement optimistic updates with rollback.
  2. Combine loaders/router navigation with shared cached queries.
  3. Use Suspense, error boundaries, SSR/hydration, or persistence only where the application architecture needs them.
  4. Design query ownership, invalidation, and authentication cache cleanup as part of the feature.
Provider: <QueryClientProvider client={queryClient}>...</QueryClientProvider>
Read: useQuery({ queryKey: ["todos"], queryFn: fetchTodos })
Write: useMutation({ mutationFn: createTodo })
Refresh: queryClient.invalidateQueries({ queryKey: ["todos"] })
Write cache: queryClient.setQueryData(["todo", id], updatedTodo)
Prefetch: queryClient.prefetchQuery(productQueryOptions(id))
Require data: queryClient.ensureQueryData(productQueryOptions(id))
Dependent: useQuery({ ..., enabled: Boolean(id) })
Pagination: placeholderData: keepPreviousData
Infinite: useInfiniteQuery({ initialPageParam, getNextPageParam, ... })
Fresh data: staleTime: 60_000