TanStack Query (React Query)
TanStack Query for server state — useQuery, useMutation, query invalidation, optimistic updates, infinite queries, prefetching, and SSR hydration
TanStack Query separates server state from client state. Server state is asynchronous, shared, and can become stale — it needs fundamentally different tools than local UI state. TanStack Query provides a declarative cache with automatic background refetching, deduplication, garbage collection, and mutation support. You describe what data you need; the library handles when and how to fetch it.
## Key Points
- **Server state is not client state** — remote data has different lifecycle concerns (staleness, caching, synchronization)
- **Declarative data fetching** — components declare their data dependencies; the cache orchestrates the rest
- **Automatic staleness management** — data is refetched in the background based on configurable stale times
- **Request deduplication** — multiple components requesting the same query trigger only one network request
- **Optimistic updates** — UI can update immediately while mutations are in flight
- **Framework-agnostic core** — `@tanstack/react-query` is the React adapter
1. **Colocate query hooks** — wrap `useQuery` in custom hooks (`useTodos`, `useUser`) to centralize query keys and fetch logic.
2. **Structure query keys hierarchically** — `['todos', 'list', { status }]` and `['todos', 'detail', id]` allow precise invalidation.
3. **Set appropriate `staleTime`** — default is `0` (always stale); set higher for data that changes infrequently to reduce refetches.
4. **Use `enabled` for conditional fetching** — never call hooks conditionally; use the `enabled` option instead.
5. **Extract query key factories** — define `todoKeys.all`, `todoKeys.detail(id)` as functions to avoid key typos.
6. **Prefetch on user intent** — hover, focus, or route-level prefetching makes navigation feel instant.
## Quick Example
```bash
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools # optional
```skilldb get state-management-skills/TanStack Query (React Query)Full skill: 382 linesTanStack Query (React Query)
Core Philosophy
TanStack Query separates server state from client state. Server state is asynchronous, shared, and can become stale — it needs fundamentally different tools than local UI state. TanStack Query provides a declarative cache with automatic background refetching, deduplication, garbage collection, and mutation support. You describe what data you need; the library handles when and how to fetch it.
- Server state is not client state — remote data has different lifecycle concerns (staleness, caching, synchronization)
- Declarative data fetching — components declare their data dependencies; the cache orchestrates the rest
- Automatic staleness management — data is refetched in the background based on configurable stale times
- Request deduplication — multiple components requesting the same query trigger only one network request
- Optimistic updates — UI can update immediately while mutations are in flight
- Framework-agnostic core —
@tanstack/react-queryis the React adapter
Setup
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools # optional
// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ReactNode, useState } from 'react';
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
retry: 2,
refetchOnWindowFocus: true,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Key Techniques
useQuery — Fetching Data
// hooks/useTodos.ts
import { useQuery } from '@tanstack/react-query';
interface Todo {
id: string;
title: string;
completed: boolean;
}
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('/api/todos');
if (!res.ok) throw new Error('Failed to fetch todos');
return res.json();
}
export function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
}
// Parameterized query
export function useTodo(id: string) {
return useQuery({
queryKey: ['todos', id],
queryFn: async (): Promise<Todo> => {
const res = await fetch(`/api/todos/${id}`);
if (!res.ok) throw new Error('Todo not found');
return res.json();
},
enabled: !!id, // only fetch when id is truthy
});
}
// components/TodoList.tsx
import { useTodos } from '../hooks/useTodos';
export function TodoList() {
const { data: todos, isLoading, isError, error } = useTodos();
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
useMutation — Modifying Data
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateTodoInput {
title: string;
}
export function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: CreateTodoInput): Promise<Todo> => {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(input),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error('Failed to create todo');
return res.json();
},
onSuccess: () => {
// Invalidate and refetch the todos list
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
function AddTodoForm() {
const createTodo = useCreateTodo();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createTodo.mutate(
{ title: formData.get('title') as string },
{
onSuccess: () => e.currentTarget.reset(),
onError: (err) => alert(err.message),
}
);
};
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<button type="submit" disabled={createTodo.isPending}>
{createTodo.isPending ? 'Adding...' : 'Add'}
</button>
</form>
);
}
Query Invalidation Strategies
const queryClient = useQueryClient();
// Invalidate one query
queryClient.invalidateQueries({ queryKey: ['todos'] });
// Invalidate all queries starting with 'todos'
queryClient.invalidateQueries({ queryKey: ['todos'], exact: false });
// Invalidate a specific todo
queryClient.invalidateQueries({ queryKey: ['todos', todoId] });
// Remove query from cache entirely
queryClient.removeQueries({ queryKey: ['todos', todoId] });
// Manually set query data (e.g., after a mutation returns the updated item)
queryClient.setQueryData(['todos', newTodo.id], newTodo);
Optimistic Updates
export function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updated: Todo): Promise<Todo> => {
const res = await fetch(`/api/todos/${updated.id}`, {
method: 'PUT',
body: JSON.stringify(updated),
headers: { 'Content-Type': 'application/json' },
});
return res.json();
},
onMutate: async (updatedTodo) => {
// Cancel outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistically update the cache
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((t) => (t.id === updatedTodo.id ? updatedTodo : t))
);
return { previousTodos };
},
onError: (_err, _todo, context) => {
// Roll back on error
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSettled: () => {
// Refetch to ensure server state is in sync
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
Infinite Queries (Pagination / Infinite Scroll)
import { useInfiniteQuery } from '@tanstack/react-query';
interface PaginatedResponse<T> {
data: T[];
nextCursor: string | null;
}
export function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }): Promise<PaginatedResponse<Post>> => {
const res = await fetch(`/api/posts?cursor=${pageParam}`);
return res.json();
},
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfinitePosts();
const allPosts = data?.pages.flatMap((page) => page.data) ?? [];
return (
<div>
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading more...' : 'Load more'}
</button>
)}
</div>
);
}
Prefetching
// Prefetch on hover for instant navigation
function TodoListItem({ id, title }: { id: string; title: string }) {
const queryClient = useQueryClient();
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodoById(id),
staleTime: 30 * 1000,
});
};
return (
<Link to={`/todos/${id}`} onMouseEnter={prefetch}>
{title}
</Link>
);
}
// Prefetch in a route loader (React Router)
export const todoLoader = (queryClient: QueryClient) =>
async ({ params }: { params: { id: string } }) => {
await queryClient.ensureQueryData({
queryKey: ['todos', params.id],
queryFn: () => fetchTodoById(params.id),
});
return null;
};
SSR Hydration (Next.js App Router)
// app/todos/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function TodosPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
);
}
Dependent Queries
function useUserProjects(userId: string | undefined) {
// First query: fetch the user
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId!),
enabled: !!userId,
});
// Second query: depends on the first
const projectsQuery = useQuery({
queryKey: ['projects', userQuery.data?.teamId],
queryFn: () => fetchProjects(userQuery.data!.teamId),
enabled: !!userQuery.data?.teamId,
});
return { user: userQuery, projects: projectsQuery };
}
Best Practices
- Colocate query hooks — wrap
useQueryin custom hooks (useTodos,useUser) to centralize query keys and fetch logic. - Structure query keys hierarchically —
['todos', 'list', { status }]and['todos', 'detail', id]allow precise invalidation. - Set appropriate
staleTime— default is0(always stale); set higher for data that changes infrequently to reduce refetches. - Use
enabledfor conditional fetching — never call hooks conditionally; use theenabledoption instead. - Extract query key factories — define
todoKeys.all,todoKeys.detail(id)as functions to avoid key typos. - Prefetch on user intent — hover, focus, or route-level prefetching makes navigation feel instant.
- Separate server state from client state — use TanStack Query for API data and Zustand/Jotai for local-only UI state.
Anti-Patterns
- Duplicating server state locally — copying fetched data into
useStateleads to stale UI and sync bugs. - Using
queryKeystrings instead of arrays — string keys don't support hierarchical invalidation. - Forgetting
onSettledinvalidation after optimistic updates — the cache can drift from the server if you skip the final refetch. - Setting
staleTime: Infinityeverywhere — data never refetches automatically; appropriate only for truly static data. - Calling
queryClientmethods during render — side effects likeinvalidateQueriesmust happen in event handlers or mutation callbacks, not in the component body. - Ignoring error boundaries — unhandled query errors crash the app; use
throwOnErrorwith React error boundaries or handle inline.
Install this skill directly: skilldb add state-management-skills
Related Skills
Jotai Atomic State Management
Jotai atomic state for React — primitive/derived/async atoms, Provider-less mode, atomWithStorage, atomWithQuery, and React Suspense integration
Legend State
Legend-State high-performance observable state for React — fine-grained reactivity, persistence plugins, computed observables, and sync engine
Nanostores
Nanostores tiny framework-agnostic state manager — atoms, computed stores, lifecycle events, and integrations with React, Vue, Svelte, and vanilla JS
Redux Toolkit
Redux Toolkit for scalable React state — createSlice, configureStore, RTK Query, createAsyncThunk, entity adapter, middleware, and TypeScript patterns
Valtio Proxy State Management
Valtio proxy-based state for React — mutable-style API with automatic tracking, snapshots, derived state, and nested object support
XState State Machines
XState for state machines and statecharts in React — actors, guards, actions, services, @xstate/react integration, and the visual editor