tanstack query
Server state management for React applications
$ npx docs2skills add tanstack-query-reactTanStack Query
Server state management for React applications
What this skill does
TanStack Query is the missing data-fetching library for React applications. It transforms server state management by providing intelligent caching, background synchronization, and automatic updates. Unlike client state libraries (Redux, Zustand), TanStack Query specializes in async server state that's persisted remotely and can become stale.
It eliminates the need for manual cache management, loading states, and request deduplication. Out of the box, it provides background refetching, stale-while-revalidate patterns, optimistic updates, and memory management. This dramatically reduces boilerplate code while making apps feel faster and more responsive.
TanStack Query handles the hardest problems in data fetching: caching strategies, request deduplication, background updates, knowing when data is stale, pagination, infinite scrolling, and memory optimization.
Prerequisites
- React 16.8+ (hooks support)
- TypeScript 4.1+ (if using TypeScript)
- Modern bundler (Vite, Webpack, etc.)
Quick start
npm install @tanstack/react-query
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<Posts />
</QueryClientProvider>
)
}
function Posts() {
const { data, isPending, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json())
})
if (isPending) return 'Loading...'
if (error) return 'Error: ' + error.message
return <div>{data.map(post => <div key={post.id}>{post.title}</div>)}</div>
}
Core concepts
Query Keys: Unique identifiers that describe data dependencies. Arrays that can include strings, numbers, objects. Changes to query keys trigger refetches.
Query Functions: Async functions that return data or throw errors. Must be deterministic for a given query key.
Cache: In-memory storage with intelligent invalidation, garbage collection, and structural sharing for performance.
Background Refetching: Automatic data updates when window regains focus, network reconnects, or at intervals.
Stale-while-revalidate: Serves cached data immediately while fetching fresh data in the background.
Optimistic Updates: Update UI immediately, rollback on failure.
Key API surface
Queries
useQuery({ queryKey, queryFn, ...options })- Basic data fetchinguseQueries([{ queryKey, queryFn }, ...])- Multiple queries in paralleluseInfiniteQuery({ queryKey, queryFn, getNextPageParam })- Pagination/infinite scrolluseSuspenseQuery({ queryKey, queryFn })- Suspense integration
Mutations
useMutation({ mutationFn, onSuccess, onError })- Data modificationsuseMutationState()- Access mutation states globally
Client Operations
useQueryClient()- Access query client instancequeryClient.invalidateQueries({ queryKey })- Force refetchqueryClient.setQueryData(queryKey, data)- Manual cache updatesqueryClient.prefetchQuery({ queryKey, queryFn })- Preload data
Status Hooks
useIsFetching()- Global loading indicatoruseIsMutating()- Global mutation indicator
Options Factories
queryOptions({ queryKey, queryFn })- Reusable query configurationsinfiniteQueryOptions()- Reusable infinite query configs
Common patterns
Basic CRUD operations
function Posts() {
const queryClient = useQueryClient()
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json())
})
const createPost = useMutation({
mutationFn: (newPost) => fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost)
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
}
})
const deletePost = useMutation({
mutationFn: (id) => fetch(`/api/posts/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
}
})
}
Dependent queries
function UserPosts({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
})
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetch(`/api/posts?userId=${userId}`).then(res => res.json()),
enabled: !!user
})
}
Infinite scrolling
function InfinitePostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/posts?page=${pageParam}`).then(res => res.json()),
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 0
})
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => <div key={post.id}>{post.title}</div>)}
</div>
))}
<button
onClick={fetchNextPage}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</div>
)
}
Optimistic updates
function TodoItem({ todo }) {
const queryClient = useQueryClient()
const updateTodo = useMutation({
mutationFn: (updates) => fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
}),
onMutate: async (updates) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], old =>
old.map(t => t.id === todo.id ? { ...t, ...updates } : t)
)
return { previousTodos }
},
onError: (err, updates, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
}
Query options pattern
const postQueries = {
all: () => ({ queryKey: ['posts'] }),
lists: () => ({ queryKey: ['posts', 'list'] }),
list: (filters) => ({
queryKey: ['posts', 'list', filters],
queryFn: () => fetchPosts(filters)
}),
details: () => ({ queryKey: ['posts', 'detail'] }),
detail: (id) => ({
queryKey: ['posts', 'detail', id],
queryFn: () => fetchPost(id)
})
}
function Posts({ filters }) {
const { data } = useQuery(postQueries.list(filters))
}
Configuration
Query client defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (was cacheTime)
retry: 3,
refetchOnWindowFocus: true,
refetchOnReconnect: true
},
mutations: {
retry: 1
}
}
})
Per-query configuration
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 10 * 60 * 1000, // Don't refetch for 10 minutes
gcTime: 15 * 60 * 1000, // Keep in cache for 15 minutes
retry: (failureCount, error) => {
if (error.status === 404) return false
return failureCount < 3
},
refetchInterval: 30000, // Poll every 30 seconds
enabled: !!userId // Conditional fetching
})
Best practices
-
Use query keys consistently: Keep them hierarchical and descriptive.
['posts', 'list', filters]over['posts']. -
Colocate query definitions: Create query factories to ensure consistent keys and functions across components.
-
Handle loading and error states: Always check
isPendinganderrorbefore rendering data. -
Invalidate related queries: After mutations, invalidate all potentially affected query keys.
-
Use enabled option for dependent queries: Prevent unnecessary requests when dependencies aren't ready.
-
Optimize with select: Transform data at the query level to prevent unnecessary re-renders.
-
Prefetch for better UX: Load data before users need it using
queryClient.prefetchQuery(). -
Structure mutations properly: Use
onMutatefor optimistic updates,onErrorfor rollbacks,onSettledfor cleanup. -
Configure appropriate stale times: Set longer
staleTimefor rarely-changing data to reduce requests. -
Use Suspense boundaries: Wrap
useSuspenseQuerycomponents in error boundaries for better error handling.
Gotchas and common mistakes
Query keys must be stable: Objects in query keys should be serializable and ordered consistently. ['posts', { userId: 1 }] works, but functions don't.
Don't destructure with rest operator: const { data, ...rest } = useQuery() can cause performance issues. Destructure only what you need.
Mutations don't automatically update queries: You must invalidate or update the cache manually after successful mutations.
Window focus refetching is enabled by default: This can cause unexpected requests. Disable with refetchOnWindowFocus: false if not desired.
gcTime vs staleTime confusion: staleTime controls when data is considered fresh, gcTime controls when unused data is garbage collected.
Infinite queries need proper page param handling: getNextPageParam must return undefined when there are no more pages.
Query functions must be pure: Same inputs should always produce the same request. Side effects belong in onSuccess/onError.
Suspense queries throw promises: Must be wrapped in Suspense boundaries and error boundaries.
Parallel mutations can cause race conditions: Use await queryClient.cancelQueries() in onMutate to prevent conflicts.
Query client must be stable: Don't create new QueryClient instances on every render. Create once and reuse.
Structural sharing prevents some optimizations: If you need referential equality, use select to return stable references.
Background refetching happens during interactions: Users might see loading indicators during typing. Consider refetchOnWindowFocus: false for forms.
Query errors are thrown in Suspense mode: Error boundaries become critical for user experience.
Initial data bypasses cache: Using initialData prevents background refetching until data becomes stale.