PluginBench
Rule

tanstack query v5

via PatrickJS/awesome-cursorrules

Expert guidance for TanStack Query v5: queries, mutations, optimistic updates, infinite queries, and Suspense patterns.

What is tanstack query v5?

This rule provides comprehensive TanStack Query v5 patterns for managing server state in React applications. Use it when building data-fetching layers with queries, mutations, caching, invalidation, and prefetching strategies. Covers v5 breaking changes, TypeScript integration, and performance optimization.

  • Define reusable queries with queryOptions() helper and structured query key factories
  • Implement mutations with onSuccess/onError callbacks and cache invalidation
  • Build optimistic updates using onMutate, onError, and onSettled lifecycle hooks
  • Create infinite queries with pagination and cursor-based fetching
  • Prefetch data on hover or during routing to eliminate loading states
  • Use Suspense mode with useSuspenseQuery and ErrorBoundary for cleaner async UI

Applies to

File patterns this rule matches.

**/*
Rule definition (reference)

Source of truth, from the repository.

You are an expert in TanStack Query v5 (formerly React Query), TypeScript, and async state management for React applications.

TanStack Query v5 Guidelines

Core Philosophy

  • TanStack Query manages server state — it is NOT a general state manager for client-only state
  • Every query should have a stable, serializable query key that uniquely describes the data
  • Mutations handle writes; queries handle reads — never blur this boundary
  • Prefer queryOptions() helper for reusable, co-located query definitions
  • v5 breaking changes: useQuery no longer accepts positional args; always use the options object form

Setup

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60, // 1 minute default stale time
      retry: 2,
      refetchOnWindowFocus: true,
    },
  },
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

Query Keys

  • Always structure keys as arrays: ['entity', 'list'], ['entity', 'detail', id]
  • Use a query key factory to avoid typos and enable easy invalidation
// queryKeys.ts
export const postKeys = {
  all: ['posts'] as const,
  lists: () => [...postKeys.all, 'list'] as const,
  list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,
  details: () => [...postKeys.all, 'detail'] as const,
  detail: (id: string) => [...postKeys.details(), id] as const,
}

queryOptions Helper (v5)

  • Use queryOptions() to define queries once and reuse across components and loaders
import { queryOptions } from '@tanstack/react-query'

export const postQueryOptions = (id: string) =>
  queryOptions({
    queryKey: postKeys.detail(id),
    queryFn: () => fetchPost(id),
    staleTime: 1000 * 60 * 5, // 5 min
  })

// In component
const { data } = useQuery(postQueryOptions(postId))

// In router loader (TanStack Router integration)
loader: ({ params, context: { queryClient } }) =>
  queryClient.ensureQueryData(postQueryOptions(params.postId))

useQuery

const {
  data,
  isLoading,    // true only on first load with no cached data
  isFetching,   // true whenever a fetch is in-flight
  isError,
  error,
  isSuccess,
} = useQuery({
  queryKey: postKeys.detail(postId),
  queryFn: () => fetchPost(postId),
  enabled: !!postId, // disable query if params not ready
})

useMutation

const { mutate, mutateAsync, isPending } = useMutation({
  mutationFn: (newPost: CreatePostInput) => createPost(newPost),
  onSuccess: (data) => {
    // Invalidate and refetch
    queryClient.invalidateQueries({ queryKey: postKeys.lists() })
    toast.success('Post created!')
  },
  onError: (error) => {
    toast.error(error.message)
  },
})

// Usage
mutate({ title: 'Hello', body: '...' })

Optimistic Updates

const queryClient = useQueryClient()

const mutation = useMutation({
  mutationFn: updatePost,
  onMutate: async (updatedPost) => {
    await queryClient.cancelQueries({ queryKey: postKeys.detail(updatedPost.id) })
    const previous = queryClient.getQueryData(postKeys.detail(updatedPost.id))
    queryClient.setQueryData(postKeys.detail(updatedPost.id), updatedPost)
    return { previous }
  },
  onError: (err, updatedPost, context) => {
    queryClient.setQueryData(postKeys.detail(updatedPost.id), context?.previous)
  },
  onSettled: (_, __, updatedPost) => {
    queryClient.invalidateQueries({ queryKey: postKeys.detail(updatedPost.id) })
  },
})

Infinite Queries

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
} = useInfiniteQuery({
  queryKey: postKeys.lists(),
  queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
  initialPageParam: undefined as string | undefined,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})

// data.pages is an array of page results — flatten for rendering
const allPosts = data?.pages.flatMap((page) => page.items) ?? []

Prefetching

  • Prefetch on hover or during routing to eliminate loading states
// Hover prefetch
const handleMouseEnter = () => {
  queryClient.prefetchQuery(postQueryOptions(postId))
}

// In router loader (eliminates all loading spinners)
export const Route = createFileRoute('/posts/$postId')({
  loader: ({ context: { queryClient }, params }) =>
    queryClient.ensureQueryData(postQueryOptions(params.postId)),
})

Cache Invalidation Patterns

// Invalidate all post queries
queryClient.invalidateQueries({ queryKey: postKeys.all })

// Invalidate only post lists
queryClient.invalidateQueries({ queryKey: postKeys.lists() })

// Remove from cache entirely
queryClient.removeQueries({ queryKey: postKeys.detail(id) })

// Directly update cache without refetch
queryClient.setQueryData(postKeys.detail(id), newData)

Suspense Mode

  • Use useSuspenseQuery for Suspense-based data fetching (v5)
  • Wrap with <Suspense fallback={<Skeleton />}>
  • Pair with <ErrorBoundary> for error handling
// No need to handle isLoading — Suspense handles it
const { data } = useSuspenseQuery(postQueryOptions(postId))

Performance Best Practices

  • Set appropriate staleTime per query — defaults to 0 (always stale)
  • Use select to transform/subscribe to only relevant slices of data
  • Use placeholderData: keepPreviousData for pagination to avoid layout shifts
  • Avoid creating QueryClient inside components — instantiate once at app root
  • Use notifyOnChangeProps to limit re-renders to only relevant data changes

Error Handling

  • Use throwOnError: true to bubble errors to the nearest ErrorBoundary
  • Use retry function for conditional retry logic (e.g., skip retry on 404)
retry: (failureCount, error) => {
  if (error.status === 404) return false
  return failureCount < 3
},

TypeScript Tips

  • Always type queryFn return value explicitly or infer from typed API functions
  • Use QueryObserverResult<TData, TError> to type hook return values
  • Use UseMutationResult<TData, TError, TVariables> for mutations

Related rules

Type-safe file-based routing for React with TanStack Router v1, loaders, and search params validation.

["src/routes/**/*" +2
41k
via PatrickJS/awesome-cursorrules

Type-safe file-based routing for React with TanStack Router v1, loaders, and search validation.

**/*
41k
via PatrickJS/awesome-cursorrules

Full-stack React framework with type-safe server functions, file-based routing, and streaming via TanStack Router + Vinxi.

["src/routes/**/*" +2
41k
via PatrickJS/awesome-cursorrules

Full-stack React framework with server functions, streaming, and end-to-end type safety.

**/*
41k
via PatrickJS/awesome-cursorrules

Expert guidance for building desktop apps with Tauri, Svelte, and TypeScript.

**/*
41k
via PatrickJS/awesome-cursorrules

Best practices and conventions for building Temporal.io workflows and activities in Python.

**/*
41k
via PatrickJS/awesome-cursorrules