tanstack-query
Overview
TanStack Query (formerly React Query) manages server state - data that lives on the server and needs to be fetched, cached, synchronized, and updated. It provides automatic caching, background refetching, stale-while-revalidate patterns, pagination, infinite scrolling, and optimistic updates out of the box.
Package: @tanstack/react-query
Devtools: @tanstack/react-query-devtools
Current Version: v5
Installation
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools # Optional
Setup
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
gcTime: 1000 * 60 * 5, // 5 minutes (garbage collection)
retry: 3,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Core Concepts
Query Keys
Query keys uniquely identify cached data. They must be serializable arrays:
// Simple key
useQuery({ queryKey: ["todos"], queryFn: fetchTodos });
// With variables (dependency array pattern)
useQuery({ queryKey: ["todos", { status, page }], queryFn: fetchTodos });
// Hierarchical keys for invalidation
useQuery({ queryKey: ["todos", todoId], queryFn: () => fetchTodo(todoId) });
useQuery({
queryKey: ["todos", todoId, "comments"],
queryFn: () => fetchComments(todoId),
});
// Invalidation matches prefixes:
// queryClient.invalidateQueries({ queryKey: ['todos'] })
// ^ Invalidates ALL queries starting with 'todos'
Query Functions
// Query function receives a QueryFunctionContext
useQuery({
queryKey: ["todos", todoId],
queryFn: async ({ queryKey, signal, meta }) => {
const [_key, id] = queryKey;
const response = await fetch(`/api/todos/${id}`, { signal });
if (!response.ok) throw new Error("Failed to fetch");
return response.json();
},
});
// Using the signal for automatic cancellation
useQuery({
queryKey: ["todos"],
queryFn: async ({ signal }) => {
const response = await fetch("/api/todos", { signal });
return response.json();
},
});
queryOptions Helper
Create reusable, type-safe query configurations:
import { queryOptions } from "@tanstack/react-query";
export const todosQueryOptions = queryOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
staleTime: 5000,
});
export const todoQueryOptions = (todoId: string) =>
queryOptions({
queryKey: ["todos", todoId],
queryFn: () => fetchTodo(todoId),
enabled: !!todoId,
});
// Usage
const { data } = useQuery(todosQueryOptions);
const { data } = useSuspenseQuery(todoQueryOptions(id));
await queryClient.prefetchQuery(todosQueryOptions);
Queries (useQuery)
Basic Usage
import { useQuery } from "@tanstack/react-query";
function Todos() {
const {
data,
error,
isLoading, // First load, no data yet
isFetching, // Any fetch in progress (including background)
isError,
isSuccess,
isPending, // No data yet (same as isLoading in most cases)
status, // 'pending' | 'error' | 'success'
fetchStatus, // 'fetching' | 'paused' | 'idle'
refetch,
isStale,
isPlaceholderData,
dataUpdatedAt,
errorUpdatedAt,
} = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
if (isLoading) return <Spinner />;
if (isError) return <Error message={error.message} />;
return <TodoList todos={data} />;
}
Query Options
useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
// Freshness
staleTime: 5000, // ms data stays fresh (default: 0)
gcTime: 300000, // ms unused data stays in cache (default: 5 min)
// Refetching
refetchInterval: 10000, // Poll every 10s
refetchIntervalInBackground: false, // Don't poll when tab hidden
refetchOnMount: true, // Refetch on component mount if stale
refetchOnWindowFocus: true, // Refetch on window focus if stale
refetchOnReconnect: true, // Refetch on network reconnect
// Retry
retry: 3, // Number of retries (or function)
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Conditional
enabled: !!userId, // Only run when truthy
// Initial/placeholder data
initialData: () => cachedData,
initialDataUpdatedAt: Date.now() - 10000,
placeholderData: (previousData) => previousData, // keepPreviousData pattern
placeholderData: initialTodos,
// Transform
select: (data) => data.filter((todo) => !todo.done),
// Structural sharing (default: true)
structuralSharing: true,
// Network mode
networkMode: "online", // 'online' | 'always' | 'offlineFirst'
// Meta (accessible in query function context)
meta: { purpose: "user-facing" },
});
Mutations (useMutation)
Basic Usage
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo: { title: string }) => {
return fetch("/api/todos", {
method: "POST",
body: JSON.stringify(newTodo),
}).then((res) => res.json());
},
// Lifecycle callbacks
onMutate: async (variables) => {
// Called before mutationFn
// Good for optimistic updates
return { previousTodos }; // context for onError
},
onSuccess: (data, variables, context) => {
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
onError: (error, variables, context) => {
// Rollback optimistic updates
queryClient.setQueryData(["todos"], context.previousTodos);
},
onSettled: (data, error, variables, context) => {
// Always runs (success or error)
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<button
onClick={() => mutation.mutate({ title: "New Todo" })}
disabled={mutation.isPending}
>
{mutation.isPending ? "Adding..." : "Add Todo"}
</button>
);
}
Mutation State
const {
mutate, // Fire-and-forget
mutateAsync, // Returns promise
isPending, // Mutation in progress
isError,
isSuccess,
isIdle, // Not yet fired
data, // Success response
error, // Error object
reset, // Reset state to idle
variables, // Variables passed to mutate
status, // 'idle' | 'pending' | 'error' | 'success'
} = useMutation({ ... })
Optimistic Updates
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 1. Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ["todos", newTodo.id] });
// 2. Snapshot previous value
const previousTodo = queryClient.getQueryData(["todos", newTodo.id]);
// 3. Optimistically update
queryClient.setQueryData(["todos", newTodo.id], newTodo);
// 4. Return context for rollback
return { previousTodo };
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(["todos", newTodo.id], context.previousTodo);
},
onSettled: () => {
// Always refetch to sync with server
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
Optimistic Updates on Lists
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
return { previousTodos }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
Query Invalidation
const queryClient = useQueryClient();
// Invalidate all queries
queryClient.invalidateQueries();
// Invalidate by prefix
queryClient.invalidateQueries({ queryKey: ["todos"] });
// Invalidate exact match
queryClient.invalidateQueries({ queryKey: ["todos", 1], exact: true });
// Invalidate with predicate
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "todos" && query.queryKey[1]?.status === "done",
});
// Invalidate and refetch immediately
queryClient.refetchQueries({ queryKey: ["todos"] });
// Remove from cache entirely
queryClient.removeQueries({ queryKey: ["todos", 1] });
// Reset to initial state
queryClient.resetQueries({ queryKey: ["todos"] });
Infinite Queries
import { useInfiniteQuery } from "@tanstack/react-query";
function InfiniteList() {
const {
data,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
} = useInfiniteQuery({
queryKey: ["projects"],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/projects?cursor=${pageParam}`);
return res.json();
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.nextCursor ?? undefined; // undefined = no more pages
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
return firstPage.prevCursor ?? undefined;
},
maxPages: 3, // Keep max 3 pages in cache (for performance)
});
return (
<div>
{data.pages.map((page) =>
page.items.map((item) => <Item key={item.id} item={item} />),
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? "Loading..."
: hasNextPage
? "Load More"
: "No more"}
</button>
</div>
);
}
Parallel Queries
// Multiple independent queries run in parallel automatically
function Dashboard() {
const usersQuery = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
});
// Both fetch simultaneously
}
// Dynamic parallel queries with useQueries
function UserProjects({ userIds }) {
const queries = useQueries({
queries: userIds.map((id) => ({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
})),
combine: (results) => ({
data: results.map((r) => r.data),
pending: results.some((r) => r.isPending),
}),
});
}
Dependent Queries
// Sequential queries using enabled
function UserPosts({ userId }) {
const userQuery = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
const postsQuery = useQuery({
queryKey: ["posts", userId],
queryFn: () => fetchPostsByUser(userId),
enabled: !!userQuery.data, // Only run when user is loaded
});
}
Paginated Queries
function PaginatedList() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ["todos", page],
queryFn: () => fetchTodos(page),
placeholderData: (previousData) => previousData, // Keep showing old data
});
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data.items.map((item) => (
<Item key={item.id} item={item} />
))}
<button
onClick={() => setPage((p) => p + 1)}
disabled={isPlaceholderData || !data.hasMore}
>
Next
</button>
</div>
);
}
Suspense Integration
import {
useSuspenseQuery,
useSuspenseInfiniteQuery,
} from "@tanstack/react-query";
// Component will suspend until data is loaded
function TodoList() {
const { data } = useSuspenseQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
// data is guaranteed to be defined here
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
// Wrap with Suspense boundary
function App() {
return (
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<TodoList />
</Suspense>
</ErrorBoundary>
);
}
// Multiple suspense queries (fetch in parallel)
function Dashboard() {
const [{ data: users }, { data: projects }] = useSuspenseQueries({
queries: [
{ queryKey: ["users"], queryFn: fetchUsers },
{ queryKey: ["projects"], queryFn: fetchProjects },
],
});
}
Prefetching
const queryClient = useQueryClient();
// Prefetch on hover
function TodoLink({ todoId }) {
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ["todo", todoId],
queryFn: () => fetchTodo(todoId),
staleTime: 5000, // Only prefetch if data older than 5s
});
};
return (
<Link to={`/todos/${todoId}`} onMouseEnter={prefetch}>
Todo {todoId}
</Link>
);
}
// Prefetch in route loader (TanStack Router integration)
export const Route = createFileRoute("/todos/$todoId")({
loader: ({ context: { queryClient }, params: { todoId } }) =>
queryClient.ensureQueryData(todoQueryOptions(todoId)),
});
// Prefetch infinite queries
queryClient.prefetchInfiniteQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
initialPageParam: 0,
pages: 3, // Prefetch first 3 pages
});
SSR & Hydration
Server-Side Prefetching
// Server component or loader
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
async function getServerSideProps() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
function Page({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Todos />
</HydrationBoundary>
);
}
Streaming SSR (React Server Components)
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { makeQueryClient } from "./query-client";
export default async function Page() {
const queryClient = makeQueryClient();
// Prefetch on server
await queryClient.prefetchQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
);
}
QueryClient API
const queryClient = useQueryClient();
// Get cached data
queryClient.getQueryData(["todos"]);
// Set cached data
queryClient.setQueryData(["todos"], updatedTodos);
queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);
// Get query state
queryClient.getQueryState(["todos"]);
// Check if fetching
queryClient.isFetching({ queryKey: ["todos"] });
queryClient.isMutating();
// Cancel queries
queryClient.cancelQueries({ queryKey: ["todos"] });
// Invalidate (marks stale, refetches active)
queryClient.invalidateQueries({ queryKey: ["todos"] });
// Refetch (force refetch even if fresh)
queryClient.refetchQueries({ queryKey: ["todos"] });
// Remove from cache
queryClient.removeQueries({ queryKey: ["todos"] });
// Reset to initial state
queryClient.resetQueries({ queryKey: ["todos"] });
// Clear entire cache
queryClient.clear();
// Prefetch
queryClient.prefetchQuery({ queryKey: ["todos"], queryFn: fetchTodos });
queryClient.ensureQueryData({ queryKey: ["todos"], queryFn: fetchTodos });
// Get/set defaults
queryClient.setQueryDefaults(["todos"], { staleTime: 10000 });
queryClient.getQueryDefaults(["todos"]);
queryClient.setMutationDefaults(["addTodo"], { mutationFn: addTodo });
Testing
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // Don't retry in tests
gcTime: Infinity, // Prevent garbage collection during tests
},
},
});
return ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
test("fetches todos", async () => {
const { result } = renderHook(
() =>
useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
}),
{ wrapper: createWrapper() },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(expectedTodos);
});
// Mock with setQueryData for component tests
test("renders todos", () => {
const queryClient = new QueryClient();
queryClient.setQueryData(["todos"], mockTodos);
render(
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>,
);
expect(screen.getByText("Todo 1")).toBeInTheDocument();
});
TypeScript Patterns
Typing Query Functions
interface Todo {
id: number;
title: string;
completed: boolean;
}
// Type is inferred from queryFn return type
const { data } = useQuery({
queryKey: ["todos"],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch("/api/todos");
return res.json();
},
});
// data: Todo[] | undefined
// With select
const { data } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
select: (data): string[] => data.map((t) => t.title),
});
// data: string[] | undefined
Typing Errors
// Default error type is Error
const { error } = useQuery<Todo[], AxiosError>({
queryKey: ["todos"],
queryFn: fetchTodos,
});
// Or register globally
declare module "@tanstack/react-query" {
interface Register {
defaultError: AxiosError;
}
}
Query Options Pattern (Recommended)
import { queryOptions, infiniteQueryOptions } from "@tanstack/react-query";
export const todosOptions = queryOptions({
queryKey: ["todos"] as const,
queryFn: fetchTodos,
staleTime: 5000,
});
export const todoOptions = (id: string) =>
queryOptions({
queryKey: ["todos", id] as const,
queryFn: () => fetchTodo(id),
enabled: !!id,
});
// Full type inference everywhere
const { data } = useQuery(todosOptions);
const { data } = useSuspenseQuery(todoOptions("123"));
await queryClient.ensureQueryData(todosOptions);
queryClient.invalidateQueries({ queryKey: todosOptions.queryKey });
Advanced Patterns
Window Focus Refetching
// Disable globally
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false },
},
});
// Custom focus manager
import { focusManager } from "@tanstack/react-query";
// For React Native
focusManager.setEventListener((handleFocus) => {
const subscription = AppState.addEventListener("change", (state) => {
handleFocus(state === "active");
});
return () => subscription.remove();
});
Network Mode
useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
// 'online' (default): only fetch when online
// 'always': always fetch (useful for local-first)
// 'offlineFirst': try fetch, use cache if offline
networkMode: "offlineFirst",
});
Query Cancellation
useQuery({
queryKey: ["todos"],
queryFn: async ({ signal }) => {
// signal is AbortSignal - automatically cancelled on unmount or key change
const res = await fetch("/api/todos", { signal });
return res.json();
},
});
// Manual cancellation
queryClient.cancelQueries({ queryKey: ["todos"] });
Persistence
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
});
Best Practices
- Use
queryOptionshelper for type-safe, reusable query configurations - Structure query keys hierarchically for granular invalidation
- Set appropriate
staleTime- 0 means always refetch on mount (default), increase for less dynamic data - Use
placeholderData(notinitialData) for keeping previous page data during pagination - Prefer
useSuspenseQuerywhen using Suspense boundaries for cleaner component code - Use
enabledfor dependent queries, not conditional hook calls - Always invalidate after mutations - don't rely solely on optimistic updates
- Cancel queries in
onMutatebefore optimistic updates to prevent race conditions - Use
ensureQueryDatain route loaders instead ofprefetchQueryfor immediate access - Set
retry: falsein tests to avoid timeout issues - Don't destructure the query result if you need to pass it around (breaks reactivity)
- Use
selectfor derived data instead of transforming in the component - Keep query functions pure - they should only fetch, not cause side effects
- Use
gcTime: Infinityin tests to prevent cache cleanup during assertions
Common Pitfalls
- Using
initialDatawhen you meanplaceholderData(initialData counts as "fresh" data) - Not providing
initialPageParamfor infinite queries (required in v5) - Calling hooks conditionally (violates React rules)
- Not cancelling queries before optimistic updates (race conditions)
- Setting
staleTimehigher thangcTime(data gets garbage collected while "fresh") - Forgetting to wrap tests with
QueryClientProvider - Using same
QueryClientinstance across tests (shared state) - Not awaiting
invalidateQueriesin mutation callbacks when order matters
More from frostfoe7/rdz
tailwindcss-mobile-first
Comprehensive mobile-first responsive design patterns with 2025/2026 best practices for Tailwind CSS v4
20vercel-react-best-practices
React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
17react:components
Converts Stitch designs into modular Vite and React components using system-level networking and AST-based validation.
17supabase-postgres-best-practices
Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.
17next-best-practices
Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling
17web-design-guidelines
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
14