| name | tanstack-query |
| description | Powerful asynchronous state management, server-state utilities, and data fetching for TS/JS, React, Vue, Solid, Svelte & Angular. |
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
Setup
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 5,
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:
useQuery({ queryKey: ["todos"], queryFn: fetchTodos });
useQuery({ queryKey: ["todos", { status, page }], queryFn: fetchTodos });
useQuery({ queryKey: ["todos", todoId], queryFn: () => fetchTodo(todoId) });
useQuery({
queryKey: ["todos", todoId, "comments"],
queryFn: () => fetchComments(todoId),
});
Query Functions
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();
},
});
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,
});
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,
isFetching,
isError,
isSuccess,
isPending,
status,
fetchStatus,
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,
staleTime: 5000,
gcTime: 300000,
refetchInterval: 10000,
refetchIntervalInBackground: false,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
enabled: !!userId,
initialData: () => cachedData,
initialDataUpdatedAt: Date.now() - 10000,
placeholderData: (previousData) => previousData,
placeholderData: initialTodos,
select: (data) => data.filter((todo) => !todo.done),
structuralSharing: true,
networkMode: "online",
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());
},
onMutate: async (variables) => {
return { previousTodos };
},
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
onError: (error, variables, context) => {
queryClient.setQueryData(["todos"], context.previousTodos);
},
onSettled: (data, error, variables, context) => {
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,
mutateAsync,
isPending,
isError,
isSuccess,
isIdle,
data,
error,
reset,
variables,
status,
} = useMutation({ ... })
Optimistic Updates
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ["todos", newTodo.id] });
const previousTodo = queryClient.getQueryData(["todos", newTodo.id]);
queryClient.setQueryData(["todos", newTodo.id], newTodo);
return { previousTodo };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(["todos", newTodo.id], context.previousTodo);
},
onSettled: () => {
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();
queryClient.invalidateQueries();
queryClient.invalidateQueries({ queryKey: ["todos"] });
queryClient.invalidateQueries({ queryKey: ["todos", 1], exact: true });
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "todos" && query.queryKey[1]?.status === "done",
});
queryClient.refetchQueries({ queryKey: ["todos"] });
queryClient.removeQueries({ queryKey: ["todos", 1] });
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;
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
return firstPage.prevCursor ?? undefined;
},
maxPages: 3,
});
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
function Dashboard() {
const usersQuery = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
});
}
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
function UserPosts({ userId }) {
const userQuery = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
const postsQuery = useQuery({
queryKey: ["posts", userId],
queryFn: () => fetchPostsByUser(userId),
enabled: !!userQuery.data,
});
}
Paginated Queries
function PaginatedList() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ["todos", page],
queryFn: () => fetchTodos(page),
placeholderData: (previousData) => previousData,
});
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";
function TodoList() {
const { data } = useSuspenseQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
function App() {
return (
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<TodoList />
</Suspense>
</ErrorBoundary>
);
}
function Dashboard() {
const [{ data: users }, { data: projects }] = useSuspenseQueries({
queries: [
{ queryKey: ["users"], queryFn: fetchUsers },
{ queryKey: ["projects"], queryFn: fetchProjects },
],
});
}
Prefetching
const queryClient = useQueryClient();
function TodoLink({ todoId }) {
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ["todo", todoId],
queryFn: () => fetchTodo(todoId),
staleTime: 5000,
});
};
return (
<Link to={`/todos/${todoId}`} onMouseEnter={prefetch}>
Todo {todoId}
</Link>
);
}
export const Route = createFileRoute("/todos/$todoId")({
loader: ({ context: { queryClient }, params: { todoId } }) =>
queryClient.ensureQueryData(todoQueryOptions(todoId)),
});
queryClient.prefetchInfiniteQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
initialPageParam: 0,
pages: 3,
});
SSR & Hydration
Server-Side Prefetching
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();
await queryClient.prefetchQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
);
}
QueryClient API
const queryClient = useQueryClient();
queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], updatedTodos);
queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);
queryClient.getQueryState(["todos"]);
queryClient.isFetching({ queryKey: ["todos"] });
queryClient.isMutating();
queryClient.cancelQueries({ queryKey: ["todos"] });
queryClient.invalidateQueries({ queryKey: ["todos"] });
queryClient.refetchQueries({ queryKey: ["todos"] });
queryClient.removeQueries({ queryKey: ["todos"] });
queryClient.resetQueries({ queryKey: ["todos"] });
queryClient.clear();
queryClient.prefetchQuery({ queryKey: ["todos"], queryFn: fetchTodos });
queryClient.ensureQueryData({ queryKey: ["todos"], queryFn: fetchTodos });
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,
gcTime: Infinity,
},
},
});
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);
});
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;
}
const { data } = useQuery({
queryKey: ["todos"],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch("/api/todos");
return res.json();
},
});
const { data } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
select: (data): string[] => data.map((t) => t.title),
});
Typing Errors
const { error } = useQuery<Todo[], AxiosError>({
queryKey: ["todos"],
queryFn: fetchTodos,
});
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,
});
const { data } = useQuery(todosOptions);
const { data } = useSuspenseQuery(todoOptions("123"));
await queryClient.ensureQueryData(todosOptions);
queryClient.invalidateQueries({ queryKey: todosOptions.queryKey });
Advanced Patterns
Window Focus Refetching
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false },
},
});
import { focusManager } from "@tanstack/react-query";
focusManager.setEventListener((handleFocus) => {
const subscription = AppState.addEventListener("change", (state) => {
handleFocus(state === "active");
});
return () => subscription.remove();
});
Network Mode
useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
networkMode: "offlineFirst",
});
Query Cancellation
useQuery({
queryKey: ["todos"],
queryFn: async ({ signal }) => {
const res = await fetch("/api/todos", { signal });
return res.json();
},
});
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,
});
Best Practices
- Use
queryOptions helper 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 (not initialData) for keeping previous page data during pagination
- Prefer
useSuspenseQuery when using Suspense boundaries for cleaner component code
- Use
enabled for dependent queries, not conditional hook calls
- Always invalidate after mutations - don't rely solely on optimistic updates
- Cancel queries in
onMutate before optimistic updates to prevent race conditions
- Use
ensureQueryData in route loaders instead of prefetchQuery for immediate access
- Set
retry: false in tests to avoid timeout issues
- Don't destructure the query result if you need to pass it around (breaks reactivity)
- Use
select for derived data instead of transforming in the component
- Keep query functions pure - they should only fetch, not cause side effects
- Use
gcTime: Infinity in tests to prevent cache cleanup during assertions
Common Pitfalls
- Using
initialData when you mean placeholderData (initialData counts as "fresh" data)
- Not providing
initialPageParam for infinite queries (required in v5)
- Calling hooks conditionally (violates React rules)
- Not cancelling queries before optimistic updates (race conditions)
- Setting
staleTime higher than gcTime (data gets garbage collected while "fresh")
- Forgetting to wrap tests with
QueryClientProvider
- Using same
QueryClient instance across tests (shared state)
- Not awaiting
invalidateQueries in mutation callbacks when order matters