TanStack Query (formerly React Query) v5 introduced significant API improvements and performance enhancements. After shipping dozens of production apps with it, here are the patterns that actually work at scale.
Query Key Factories
The single most impactful pattern: structured query key factories. Ad-hoc string keys create subtle bugs and make cache invalidation unpredictable.
// queries/userKeys.ts
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
// Usage
const { data } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
})
// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: userKeys.all })
// Invalidate just the list
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
Typed Query Options
v5 introduced queryOptions() for reusable, type-safe query configurations:
import { queryOptions } from '@tanstack/react-query'
export const userQueryOptions = (userId: string) =>
queryOptions({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5 minutes
})
// In a component
const { data: user } = useQuery(userQueryOptions(userId))
// In a route loader (TanStack Router integration)
export const Route = createFileRoute('/users/$userId')({
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(userQueryOptions(params.userId)),
})
Smart Stale Times
Don't use the default staleTime: 0 for everything. Think about how often data changes:
// User preferences rarely change — cache for 1 hour
const { data: preferences } = useQuery({
queryKey: ['preferences'],
queryFn: fetchPreferences,
staleTime: 60 * 60 * 1000,
})
// Feed data changes constantly — no cache
const { data: feed } = useQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
staleTime: 0,
refetchInterval: 30_000,
})
// Product catalog — medium cache
const { data: products } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
staleTime: 5 * 60 * 1000,
})
Optimistic Mutations
Optimistic updates make your UI feel instant. v5 makes this much cleaner:
function useUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Cancel in-flight queries to avoid overwriting optimistic update
await queryClient.cancelQueries({ queryKey: todoKeys.detail(updatedTodo.id) })
// Snapshot the previous value for rollback
const previous = queryClient.getQueryData(todoKeys.detail(updatedTodo.id))
// Optimistically update
queryClient.setQueryData(todoKeys.detail(updatedTodo.id), (old) => ({
...old,
...updatedTodo,
}))
return { previous }
},
onError: (err, updatedTodo, context) => {
// Rollback on failure
queryClient.setQueryData(todoKeys.detail(updatedTodo.id), context?.previous)
},
onSettled: (data, error, updatedTodo) => {
// Always refetch after mutation
queryClient.invalidateQueries({ queryKey: todoKeys.detail(updatedTodo.id) })
},
})
}
Infinite Queries with Cursor Pagination
Cursor-based pagination is more reliable than offset pagination for live data:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts', filters],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, ...filters }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// Flatten pages for rendering
const posts = data?.pages.flatMap((page) => page.items) ?? []
Error Boundaries Integration
Combine TanStack Query's throwOnError with React Error Boundaries:
// Throw errors to nearest error boundary
const { data } = useQuery({
queryKey: ['critical-data'],
queryFn: fetchCriticalData,
throwOnError: true,
})
// In your route or component tree
function RouteErrorBoundary({ error }: { error: Error }) {
return (
<div className="error-state">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
)
}
Prefetching for UX
Prefetch data on hover to eliminate loading states entirely:
function PostCard({ post }: { post: Post }) {
const queryClient = useQueryClient()
return (
<Link
to="/posts/$postId"
params={{ postId: post.id }}
onMouseEnter={() => {
queryClient.prefetchQuery(postQueryOptions(post.id))
}}
>
{post.title}
</Link>
)
}
Wrapping Up
The most important takeaways:
- Query key factories — structure your keys from day one
queryOptions()— share query config between components and loaders- Intentional stale times — match cache duration to data change frequency
- Optimistic updates — always handle rollback and re-fetch on settle
- Prefetch on hover — the cheapest performance win you'll ever find
These patterns have saved countless hours debugging stale data and cache invalidation bugs in production. Start with key factories and work from there.