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
```tsx
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
```ts
// 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