All articles
Best Practices

TanStack Query v5 Best Practices

Master TanStack Query v5 with practical patterns for data fetching, caching, mutations, and optimistic updates in production React applications.

Share:
Maya Patel
Maya Patel
May 5, 202610 min read

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.

ts
// 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:

ts
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:

ts
// 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:

ts
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:

ts
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:

tsx
// 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:

tsx
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:

  1. Query key factories — structure your keys from day one
  2. queryOptions() — share query config between components and loaders
  3. Intentional stale times — match cache duration to data change frequency
  4. Optimistic updates — always handle rollback and re-fetch on settle
  5. 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.

Back to all articles
Previous

Getting Started with TanStack Router v1

A complete guide to building type-safe, file-based routing in your React application using TanStack Router v1 — the new stable release.

Next

Type-Safe Routing: A Deep Dive

Explore how TanStack Router achieves end-to-end type safety for routes, params, search params, and loader data — and why it matters for large teams.