tanstack-query
TanStack Query v5 Best Practices
Core Principles
- TanStack Query manages server state — async data from APIs, databases, etc. Don't use it for client-only state (forms, UI toggles, modals).
- All hooks use a single object signature (v5 breaking change):
useQuery({ queryKey, queryFn, ...options });
useMutation({ mutationFn, ...options });
Query Keys
Design query keys as hierarchical arrays for granular invalidation:
const queryKeys = {
users: {
all: ["users"] as const,
lists: () => [...queryKeys.users.all, "list"] as const,
list: (filters: UserFilters) => [...queryKeys.users.lists(), filters] as const,
details: () => [...queryKeys.users.all, "detail"] as const,
detail: (id: string) => [...queryKeys.users.details(), id] as const,
},
} as const;
Use a query key factory per domain entity. This enables:
queryClient.invalidateQueries({ queryKey: queryKeys.users.all })— invalidate everything user-related.queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() })— invalidate only lists.
Custom Query Hooks
Wrap useQuery in custom hooks — never call useQuery directly in components:
function useUser(id: string) {
return useQuery({
queryKey: queryKeys.users.detail(id),
queryFn: () => api.users.getById(id),
staleTime: 5 * 60 * 1000,
});
}
function useUsers(filters: UserFilters) {
return useQuery({
queryKey: queryKeys.users.list(filters),
queryFn: () => api.users.list(filters),
});
}
This collocates query configuration, makes queries reusable, and provides a single place to update options.
Important Defaults
Understand the defaults before overriding them:
| Default | Value | Notes |
|---|---|---|
staleTime |
0 |
Data is immediately stale; refetches on mount/focus/reconnect |
gcTime |
5 min | Inactive cache entries garbage collected after 5 minutes |
retry |
3 |
Failed queries retry 3 times with exponential backoff |
refetchOnWindowFocus |
true |
Stale queries refetch when window regains focus |
structuralSharing |
true |
Results are referentially stable if data hasn't changed |
Set staleTime appropriately for your data — data that rarely changes can have staleTime: Infinity.
Mutations
Basic Mutation Hook
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateUserInput) => api.users.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
},
});
}
Direct Cache Updates
When the mutation response contains the updated data, update the cache directly instead of refetching:
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateUserInput }) => api.users.update(id, data),
onSuccess: (updatedUser) => {
queryClient.setQueryData(queryKeys.users.detail(updatedUser.id), updatedUser);
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
},
});
}
Always use immutable updates with setQueryData — spread or structuredClone, never mutate in place.
Optimistic Updates
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.todos.toggle(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: queryKeys.todos.detail(id) });
const previous = queryClient.getQueryData(queryKeys.todos.detail(id));
queryClient.setQueryData(queryKeys.todos.detail(id), (old: Todo) => ({
...old,
completed: !old.completed,
}));
return { previous };
},
onError: (_err, id, context) => {
queryClient.setQueryData(queryKeys.todos.detail(id), context?.previous);
},
onSettled: (_data, _err, id) => {
queryClient.invalidateQueries({ queryKey: queryKeys.todos.detail(id) });
},
});
}
Dependent Queries
Use enabled to defer queries until prerequisites are available:
function useUserPosts(userId: string | undefined) {
return useQuery({
queryKey: queryKeys.posts.byUser(userId!),
queryFn: () => api.posts.getByUser(userId!),
enabled: !!userId,
});
}
Infinite Queries
function useInfiniteUsers() {
return useInfiniteQuery({
queryKey: queryKeys.users.lists(),
queryFn: ({ pageParam }) => api.users.list({ cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
Suspense Integration
function useUserSuspense(id: string) {
return useSuspenseQuery({
queryKey: queryKeys.users.detail(id),
queryFn: () => api.users.getById(id),
});
}
useSuspenseQuery guarantees data is never undefined — no need for loading/error checks in the component. Wrap the parent in <Suspense> and <ErrorBoundary>.
Query Organization
src/
├── api/ # API client functions (no TanStack Query here)
│ └── users.ts
├── queries/ # query hooks and key factories
│ ├── keys.ts # all query key factories
│ ├── users.ts # useUser, useUsers, useCreateUser, etc.
│ └── posts.ts
Keep API functions pure (return promises), and keep TanStack Query hooks in a separate layer. This makes the API layer testable without TanStack Query and keeps query hooks thin.
Anti-Patterns to Avoid
- Don't use
useEffectto sync query data into local state — use the query result directly. - Don't duplicate server state in
useState— TanStack Query is your cache. - Don't invalidate everything — use specific query keys to invalidate only what changed.
- Don't forget
enabled: falsewhen a query depends on runtime data that might beundefined.
More from grahamcrackers/skills
bulletproof-react-patterns
Bulletproof React architecture patterns for scalable, maintainable applications. Covers feature-based project structure, component patterns, state management boundaries, API layer design, error handling, security, and testing strategies. Use when structuring a React project, designing application architecture, organizing features, or when the user asks about React project structure or scalable patterns.
44react-aria-components
React Aria Components patterns for building accessible, unstyled UI with composition-based architecture. Covers component structure, styling with Tailwind and CSS, render props, collections, forms, selections, overlays, and drag-and-drop. Use when building accessible components, using react-aria-components, creating design systems, or when the user asks about React Aria, accessible UI primitives, or headless component libraries.
15clean-code-principles
Clean code principles for readable, maintainable TypeScript and React codebases. Covers naming, functions, abstraction, composition, error handling, comments, and code smells. Use when writing new code, refactoring, reviewing code quality, or when the user asks about clean code, readability, or maintainability.
10typescript-best-practices
Core TypeScript conventions for type safety, inference, and clean code. Use when writing TypeScript, reviewing TypeScript code, creating interfaces/types, or when the user asks about TypeScript patterns, conventions, or best practices.
9zustand
Zustand state management patterns for React including store design, selectors, slices, middleware (immer, persist, devtools), and async actions. Use when managing client-side state, creating stores, working with Zustand, or when the user asks about global state management, store patterns, or state persistence.
7react-best-practices
Modern React 19 patterns for components, hooks, state management, performance, and project structure. Use when writing React components, reviewing React code, designing component APIs, or when the user asks about React conventions, architecture, or best practices.
7