TanStack Query v5 cheatsheet for React from fetching and caching to mutations, optimistic updates, pagination, prefetching, and production patterns.
TanStack Query Cheatsheet
Section titled “TanStack Query Cheatsheet”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.
| Problem | TanStack Query Tool |
|---|---|
| Fetch and cache data | useQuery |
| Change server data | useMutation |
| Refresh data after a change | queryClient.invalidateQueries |
| Fetch before rendering a screen | prefetchQuery / ensureQueryData |
| Paginated or infinite lists | useQuery / useInfiniteQuery |
| Inspect request cache in development | React 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.
1. Install and Set Up
Section titled “1. Install and Set Up”npm install @tanstack/react-queryOptional development tools:
npm install @tanstack/react-query-devtoolsnpm install -D @tanstack/eslint-plugin-queryCreate one QueryClient for the application and provide it above components that call query hooks:
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>,);| Item | Meaning |
|---|---|
QueryClient | Holds cache, defaults, and imperative cache methods |
QueryClientProvider | Makes the client available to React query hooks |
| Devtools | Displays 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.
2. Beginner: Your First Query
Section titled “2. Beginner: Your First Query”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> );}Query Result Quick Reference
Section titled “Query Result Quick Reference”| Property | Meaning |
|---|---|
data | Successful response data, or undefined before success |
error | Error thrown by queryFn after final failure |
isPending | No successfully cached data is available yet |
isError | The query reached an error state |
isSuccess | Data is available successfully |
isFetching | A 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} /> </>);3. Query Keys
Section titled “3. Query Keys”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),});| Request | Good Query Key |
|---|---|
| All todos | ["todos"] |
| One todo | ["todos", todoId] |
| Filtered todos | ["todos", { status: "done" }] |
| Search page | ["products", { query, page, sort }] |
Key Factory
Section titled “Key Factory”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));5. Important Cache Defaults
Section titled “5. Important Cache Defaults”Understanding defaults prevents surprising refetches.
| Default Behavior | What It Means |
|---|---|
| Cached query data is stale immediately | A mounted/refocused query may refresh in the background |
| Stale active queries may refetch on mount, focus, or reconnect | Users see current data when returning to the app |
| Inactive query data stays cached for 5 minutes | Returning soon can reuse previous data |
| Failed client-side queries retry 3 times | Temporary failures may recover automatically |
| Results use structural sharing | Unchanged 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, }, },});Freshness and Garbage Collection
Section titled “Freshness and Garbage Collection”useQuery({ queryKey: ["countries"], queryFn: fetchCountries, staleTime: 10 * 60 * 1000, gcTime: 30 * 60 * 1000,});| Option | Controls |
|---|---|
staleTime | How long data is considered fresh and avoids staleness-triggered refetches |
gcTime | How long unused/inactive cached data remains before removal |
refetchInterval | Polling interval while the query is active |
retry | Request retries after failure |
Choose staleTime based on how quickly the server data changes, not how quickly the component renders.
6. Dependent and Conditional Queries
Section titled “6. Dependent and Conditional Queries”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.
7. Transform and Select Data
Section titled “7. Transform and Select Data”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 select | Avoid |
|---|---|
| Pick a field or derived count for display | Mutating cached objects |
| Limit what rerenders this component needs | Treating it as a replacement for server processing |
The underlying cache still stores the query function result; select transforms what this observer receives.
8. Mutations: Create, Update, Delete
Section titled “8. Mutations: Create, Update, Delete”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> )} </> );}Mutation Reference
Section titled “Mutation Reference”| Property or Option | Use |
|---|---|
mutationFn | Function that performs the server change |
mutate(variables) | Start mutation with callback-style handling |
mutateAsync(variables) | Start mutation and await a promise |
isPending | Disable duplicate submit or show progress |
isError / error | Display failure feedback |
onSuccess | Update or invalidate data after success |
onSettled | Run after either success or failure |
Submit a Form with mutateAsync
Section titled “Submit a Form with mutateAsync”async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault();
try { await createMutation.mutateAsync({ title }); setTitle(""); } catch { // The mutation error state can render the message. }}9. Invalidation and Direct Cache Updates
Section titled “9. Invalidation and Direct Cache Updates”Invalidate Related Queries
Section titled “Invalidate Related Queries”queryClient.invalidateQueries({ queryKey: ["todos"] });queryClient.invalidateQueries({ queryKey: ["todos", "list"] });Prefix matching means invalidating ["todos"] can affect detail and list queries beginning with that key.
Write a Successful Response into Cache
Section titled “Write a Successful Response into Cache”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"] }); },});Read Cached Data
Section titled “Read Cached Data”const cachedTodo = queryClient.getQueryData<Todo>(["todos", todoId]);| Method | Use It For |
|---|---|
invalidateQueries | Mark related data stale and refresh active views |
setQueryData | Synchronously update a known cache entry |
getQueryData | Read current cached data without fetching |
removeQueries | Remove cached data, such as after logout |
Do not mutate objects returned from the cache. Provide new arrays or objects to setQueryData.
10. Optimistic Updates
Section titled “10. Optimistic Updates”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"] }); },});Optimistic Update Sequence
Section titled “Optimistic Update Sequence”- Cancel an outgoing refetch that could overwrite optimistic state.
- Save the previous cached value.
- Write the optimistic value.
- Roll back when the mutation fails.
- 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.
11. Pagination
Section titled “11. Pagination”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> </> );}| Option | Behavior |
|---|---|
placeholderData: keepPreviousData | Keeps the prior page visible during a key change |
isPlaceholderData | Identifies that displayed data is temporary |
initialData | Places complete initial data into the cache |
Use placeholderData for incomplete preview/transition data. initialData is stored as real cached query data.
12. Infinite Queries
Section titled “12. Infinite Queries”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 Value | Meaning |
|---|---|
data.pages | Array of fetched response pages |
data.pageParams | Parameters used for each page |
fetchNextPage() | Request the next page |
hasNextPage | Whether getNextPageParam returned another parameter |
isFetchingNextPage | Only the next-page request is loading |
13. Prefetching
Section titled “13. Prefetching”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),);| Method | Difference |
|---|---|
prefetchQuery | Fills cache ahead of time; does not return query data |
ensureQueryData | Returns cached data or fetches it if absent |
fetchQuery | Fetches according to options and returns data |
Prefetch interactions users are likely to perform; avoid downloading entire applications speculatively.
14. Parallel Queries
Section titled “14. Parallel Queries”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.
15. Suspense and Error Boundaries
Section titled “15. Suspense and Error Boundaries”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.
16. API Function Pattern
Section titled “16. API Function Pattern”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.
17. Integrating with TanStack Router
Section titled “17. Integrating with TanStack Router”Provide the QueryClient through router context and let route loaders ensure cached data exists before rendering:
// router context ideatype 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.
18. Production Patterns
Section titled “18. Production Patterns”| Situation | Pattern |
|---|---|
| User logs out | Remove sensitive user-specific cached queries |
| API data changes rarely | Increase staleTime |
| Filter belongs in URL | Store filter in router search params; include it in query key |
| Write changes a list and detail | Update detail cache and invalidate matching lists |
| Several screens share data | Reuse queryOptions and key factories |
| Request depends on auth token | Keep token/request client in API layer or app context |
Clear Session Data on Logout
Section titled “Clear Session Data on Logout”await logout();queryClient.removeQueries({ queryKey: ["viewer"] });queryClient.removeQueries({ queryKey: ["account"] });Poll for Changing Data
Section titled “Poll for Changing Data”useQuery({ queryKey: ["jobs", jobId], queryFn: () => fetchJob(jobId), refetchInterval: (query) => query.state.data?.status === "finished" ? false : 2_000,});19. Common Mistakes and Fixes
Section titled “19. Common Mistakes and Fixes”| Mistake or Symptom | Cause | Fix |
|---|---|---|
| Different filters show same cached data | Filter missing from query key | Put every request input in the key |
404 is treated like successful data | fetch() did not throw | Check response.ok and throw |
| Refetching seems frequent | Default data becomes stale immediately | Set an appropriate staleTime |
| Form fields stored in Query cache | Server and local UI state mixed together | Keep unsaved fields in local/form state |
| POST succeeds but UI stays old | Related queries were not updated | Invalidate or set cached data on success |
| Optimistic change remains after failure | No rollback stored | Return previous state from onMutate |
| Component cannot use hooks inside a callback | Hook rules violated | Call hooks at component top level |
| Partial preview pollutes detail cache | Used initialData for incomplete data | Use placeholderData |
20. Copy-Paste Recipes
Section titled “20. Copy-Paste Recipes”Basic Query
Section titled “Basic Query”const query = useQuery({ queryKey: ["resource", id], queryFn: () => fetchResource(id),});Query with Freshness
Section titled “Query with Freshness”const query = useQuery({ queryKey: ["countries"], queryFn: fetchCountries, staleTime: 10 * 60 * 1000,});Mutation and Invalidation
Section titled “Mutation and Invalidation”const queryClient = useQueryClient();const mutation = useMutation({ mutationFn: saveItem, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["items"] }),});Prefetch
Section titled “Prefetch”await queryClient.prefetchQuery({ queryKey: ["item", id], queryFn: () => fetchItem(id),});Conditional Query
Section titled “Conditional Query”useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId!), enabled: Boolean(userId),});21. Learning Path and Quick Recall
Section titled “21. Learning Path and Quick Recall”Beginner
Section titled “Beginner”- Wrap the app in
QueryClientProvider. - Fetch a list with
useQueryand render loading/error/success states. - Learn query keys and include request parameters in them.
- Create data with
useMutationand invalidate the list afterward.
Intermediate
Section titled “Intermediate”- Reuse definitions with
queryOptionsand key factories. - Configure
staleTime, retries, and refetch behavior deliberately. - Build pagination and infinite loading experiences.
- Prefetch likely destination data and inspect the cache with Devtools.
Advanced
Section titled “Advanced”- Implement optimistic updates with rollback.
- Combine loaders/router navigation with shared cached queries.
- Use Suspense, error boundaries, SSR/hydration, or persistence only where the application architecture needs them.
- Design query ownership, invalidation, and authentication cache cleanup as part of the feature.
Recall Card
Section titled “Recall Card”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: keepPreviousDataInfinite: useInfiniteQuery({ initialPageParam, getNextPageParam, ... })Fresh data: staleTime: 60_000