Claude Code Plugins

Community-maintained marketplace

Feedback
0
0

Comprehensive TanStack Query v5 patterns for async state management. Covers breaking changes, query key factories, data transformation, mutations, optimistic updates, authentication, testing with MSW, and anti-patterns. Use for all server state management, data fetching, and cache invalidation tasks.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name tanstack-query
description Comprehensive TanStack Query v5 patterns for async state management. Covers breaking changes, query key factories, data transformation, mutations, optimistic updates, authentication, testing with MSW, and anti-patterns. Use for all server state management, data fetching, and cache invalidation tasks.

TanStack Query v5 - Complete Guide

TanStack Query v5 (October 2023) is the async state manager for this project. It requires React 18+, features first-class Suspense support, improved TypeScript inference, and a 20% smaller bundle. This section covers production-ready patterns based on official documentation and community best practices.

Breaking Changes in v5

Key updates you need to know:

  1. Single Object Signature: All hooks now accept one configuration object:

    // ✅ v5 - single object
    useQuery({ queryKey, queryFn, ...options })
    
    // ❌ v4 - multiple overloads (deprecated)
    useQuery(queryKey, queryFn, options)
    
  2. Renamed Options:

    • cacheTimegcTime (garbage collection time)
    • keepPreviousDataplaceholderData: keepPreviousData
    • isLoading now means isPending && isFetching
  3. Callbacks Removed from useQuery:

    • onSuccess, onError, onSettled removed from useQuery
    • Use global QueryCache callbacks instead
    • Prevents duplicate executions
  4. Infinite Queries Require initialPageParam:

    • No default value provided
    • Must explicitly set initialPageParam (e.g., 0 or null)
  5. First-Class Suspense:

    • New dedicated hooks: useSuspenseQuery, useSuspenseInfiniteQuery
    • No experimental flag needed
    • Data is never undefined at type level

Migration: Use the official codemod for automatic migration: npx @tanstack/query-codemods v5/replace-import-specifier

Smart Defaults

Query v5 ships with production-ready defaults:

{
  staleTime: 0,              // Data instantly stale (refetch on mount)
  gcTime: 5 * 60_000,        // Keep unused cache for 5 minutes
  retry: 3,                  // 3 retries with exponential backoff
  refetchOnWindowFocus: true,// Refetch when user returns to tab
  refetchOnReconnect: true,  // Refetch when network reconnects
}

Philosophy: React Query is an async state manager, not a data fetcher. You provide the Promise; Query manages caching, background updates, and synchronization.

Client Setup

// src/app/providers.tsx
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
import { toast } from './toast' // Your notification system

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 0,          // Adjust per-query
      gcTime: 5 * 60_000,    // 5 minutes (v5: formerly cacheTime)
      retry: (failureCount, error) => {
        // Don't retry on 401 (authentication errors)
        if (error?.response?.status === 401) return false
        return failureCount < 3
      },
    },
  },
  queryCache: new QueryCache({
    onError: (error, query) => {
      // Only show toast for background errors (when data exists)
      if (query.state.data !== undefined) {
        toast.error(`Something went wrong: ${error.message}`)
      }
    },
  }),
})

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

DevTools Setup (auto-excluded in production):

import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

<QueryClientProvider client={queryClient}>
  {children}
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

Architecture: Feature-Based Colocation

Recommended pattern: Group queries with related features, not by file type.

src/features/
├── Todos/
│   ├── index.tsx           # Feature entry point
│   ├── queries.ts          # All React Query logic (keys, functions, hooks)
│   ├── types.ts            # TypeScript types
│   └── components/         # Feature-specific components

Export only custom hooks from query files. Keep query functions and keys private:

// features/todos/queries.ts

// 1. Query Key Factory (hierarchical structure)
const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
}

// 2. Query Function (private)
const fetchTodos = async (filters: string): Promise<Todo[]> => {
  const response = await axios.get('/api/todos', { params: { filters } })
  return response.data
}

// 3. Custom Hook (public API)
export const useTodosQuery = (filters: string) => {
  return useQuery({
    queryKey: todoKeys.list(filters),
    queryFn: () => fetchTodos(filters),
    staleTime: 30_000, // Fresh for 30 seconds
  })
}

Benefits:

  • Prevents key/function mismatches
  • Clean public API
  • Encapsulation and maintainability
  • Easy to locate all query logic for a feature

Query Key Factories (Essential)

Structure keys hierarchically from generic to specific:

// ✅ Correct hierarchy
['todos']                          // Invalidates everything
['todos', 'list']                  // Invalidates all lists
['todos', 'list', { filters }]     // Invalidates specific list
['todos', 'detail', 1]             // Invalidates specific detail

// ❌ Wrong - flat structure
['todos-list-active']              // Can't partially invalidate

Critical rule: Query keys must include ALL variables used in queryFn. Treat query keys like dependency arrays:

// ✅ Correct - includes all variables
const { data } = useQuery({
  queryKey: ['todos', filters, sortBy],
  queryFn: () => fetchTodos(filters, sortBy),
})

// ❌ Wrong - missing variables
const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: () => fetchTodos(filters, sortBy), // filters/sortBy not in key!
})

Type consistency matters: ['todos', '1'] and ['todos', 1] are different keys. Be consistent with types.

Query Options API (Type Safety)

The modern pattern for maximum type safety across your codebase:

import { queryOptions } from '@tanstack/react-query'

function todoOptions(id: number) {
  return queryOptions({
    queryKey: ['todos', id],
    queryFn: () => fetchTodo(id),
    staleTime: 5000,
  })
}

// ✅ Use everywhere with full type safety
useQuery(todoOptions(1))
queryClient.prefetchQuery(todoOptions(5))
queryClient.setQueryData(todoOptions(42).queryKey, newTodo)
queryClient.getQueryData(todoOptions(42).queryKey) // Fully typed!

Benefits:

  • Single source of truth for query configuration
  • Full TypeScript inference for imperatively accessed data
  • Reusable across hooks and imperative methods
  • Prevents key/function mismatches

Data Transformation Strategies

Choose the right approach based on your use case:

1. Transform in queryFn - Simple cases where cache should store transformed data:

const fetchTodos = async (): Promise<Todo[]> => {
  const response = await axios.get('/api/todos')
  return response.data.map(todo => ({
    ...todo,
    name: todo.name.toUpperCase()
  }))
}

2. Transform with select option (RECOMMENDED) - Enables partial subscriptions:

// Only re-renders when filtered data changes
export const useTodosQuery = (filters: string) =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select: (data) => data.filter(todo => todo.status === filters),
  })

// Only re-renders when count changes
export const useTodosCount = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select: (data) => data.length,
  })

⚠️ Memoize select functions to prevent running on every render:

// ✅ Stable reference
const transformTodos = (data: Todo[]) => expensiveTransform(data)

const query = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: transformTodos, // Stable function reference
})

// ❌ Runs on every render
const query = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: (data) => expensiveTransform(data), // New function every render
})

TypeScript Best Practices

Let TypeScript infer types from queryFn rather than specifying generics:

// ✅ Recommended - inference
const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos, // Returns Promise<Todo[]>
})
// data is Todo[] | undefined

// ❌ Unnecessary - explicit generics
const { data } = useQuery<Todo[]>({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})

Discriminated unions automatically narrow types:

const { data, isSuccess, isError, error } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})

if (isSuccess) {
  // data is Todo[] (never undefined)
}

if (isError) {
  // error is defined
}

Use queryOptions helper for maximum type safety across imperative methods.

Custom Hooks Pattern

Always create custom hooks even for single queries:

// ✅ Recommended - custom hook with encapsulation
export function usePost(
  id: number,
  options?: Omit<UseQueryOptions<Post>, 'queryKey' | 'queryFn'>
) {
  return useQuery({
    queryKey: ['posts', id],
    queryFn: () => getPost(id),
    ...options,
  })
}

// Usage: allows callers to override any option except key/fn
const { data } = usePost(42, { staleTime: 10_000 })

Benefits:

  • Centralizes query logic
  • Easy to update all usages
  • Consistent configuration
  • Better testing

Error Handling (Multi-Layer Strategy)

Layer 1: Component-Level - Specific user feedback:

function TodoList() {
  const { data, error, isError, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  if (isLoading) return <Spinner />
  if (isError) return <ErrorAlert>{error.message}</ErrorAlert>

  return <ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
}

Layer 2: Global Error Handling - Background errors via QueryCache:

// Already configured in client setup above
queryCache: new QueryCache({
  onError: (error, query) => {
    if (query.state.data !== undefined) {
      toast.error(`Background error: ${error.message}`)
    }
  },
})

Layer 3: Error Boundaries - Catch render errors:

import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'

<QueryErrorResetBoundary>
  {({ reset }) => (
    <ErrorBoundary
      onReset={reset}
      fallbackRender={({ error, resetErrorBoundary }) => (
        <div>
          <p>Error: {error.message}</p>
          <button onClick={resetErrorBoundary}>Try again</button>
        </div>
      )}
    >
      <TodoList />
    </ErrorBoundary>
  )}
</QueryErrorResetBoundary>

Suspense Integration

First-class Suspense support in v5 with dedicated hooks:

import { useSuspenseQuery } from '@tanstack/react-query'

function TodoList() {
  // data is NEVER undefined (type-safe)
  const { data } = useSuspenseQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  return <ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
}

// Wrap with Suspense boundary
function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <TodoList />
    </Suspense>
  )
}

Benefits:

  • Eliminates loading state management
  • Data always defined (TypeScript enforced)
  • Cleaner component code
  • Works with React.lazy for code-splitting

Mutations with Optimistic Updates

Basic mutation with cache invalidation:

export function useCreateTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (newTodo: CreateTodoDTO) =>
      api.post('/todos', newTodo).then(res => res.data),
    onSuccess: (data) => {
      // Set detail query immediately
      queryClient.setQueryData(['todos', data.id], data)
      // Invalidate list queries
      queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
    },
  })
}

Simple optimistic updates using variables:

const addTodoMutation = useMutation({
  mutationFn: (newTodo: string) => axios.post('/api/todos', { text: newTodo }),
  onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})

const { isPending, variables, mutate } = addTodoMutation

return (
  <ul>
    {todoQuery.data?.map(todo => <li key={todo.id}>{todo.text}</li>)}
    {isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
  </ul>
)

Advanced optimistic updates with rollback:

useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // Cancel outgoing queries (prevent race conditions)
    await queryClient.cancelQueries({ queryKey: ['todos'] })

    // Snapshot current data
    const previousTodos = queryClient.getQueryData(['todos'])

    // Optimistically update cache
    queryClient.setQueryData(['todos'], (old: Todo[]) =>
      old?.map(todo => todo.id === newTodo.id ? newTodo : todo)
    )

    // Return context for rollback
    return { previousTodos }
  },
  onError: (err, newTodo, context) => {
    // Rollback on error
    queryClient.setQueryData(['todos'], context?.previousTodos)
    toast.error('Update failed. Changes reverted.')
  },
  onSettled: () => {
    // Always refetch to ensure consistency
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

Key principles:

  • Cancel ongoing queries in onMutate to prevent race conditions
  • Snapshot previous data before updating
  • Restore snapshot on error
  • Always invalidate in onSettled for eventual consistency
  • Never mutate cached data directly - always use immutable updates

Authentication Integration

Handle token refresh at HTTP client level (not React Query):

// src/lib/api-client.ts
import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'

export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
})

// Add token to requests
apiClient.interceptors.request.use((config) => {
  const token = getAccessToken()
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

// Refresh token on 401
const refreshAuth = async (failedRequest: any) => {
  try {
    const newToken = await fetchNewToken()
    failedRequest.response.config.headers.Authorization = `Bearer ${newToken}`
    setAccessToken(newToken)
    return Promise.resolve()
  } catch {
    removeAccessToken()
    window.location.href = '/login'
    return Promise.reject()
  }
}

createAuthRefreshInterceptor(apiClient, refreshAuth, {
  statusCodes: [401],
  pauseInstanceWhileRefreshing: true,
})

Protected queries use the enabled option:

const useTodos = () => {
  const { user } = useUser() // Get current user from auth context

  return useQuery({
    queryKey: ['todos', user?.id],
    queryFn: () => fetchTodos(user.id),
    enabled: !!user, // Only execute when user exists
  })
}

On logout: Clear the entire cache with queryClient.clear() (not invalidateQueries() which triggers refetches):

const logout = () => {
  removeAccessToken()
  queryClient.clear() // Clear all cached data
  navigate('/login')
}

Advanced Patterns

Prefetching - Eliminate loading states:

// Hover prefetching
function ShowDetailsButton() {
  const queryClient = useQueryClient()

  const prefetch = () => {
    queryClient.prefetchQuery({
      queryKey: ['details'],
      queryFn: getDetailsData,
      staleTime: 60_000, // Consider fresh for 1 minute
    })
  }

  return (
    <button onMouseEnter={prefetch} onClick={showDetails}>
      Show Details
    </button>
  )
}

// Route-level prefetching (see Router × Query Integration section)

Infinite Queries - Infinite scrolling/pagination:

function Projects() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: ({ pageParam }) => fetchProjects(pageParam),
    initialPageParam: 0, // Required in v5
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  })

  if (isLoading) return <Spinner />

  return (
    <>
      {data.pages.map((page, i) => (
        <React.Fragment key={i}>
          {page.data.map(project => (
            <ProjectCard key={project.id} {...project} />
          ))}
        </React.Fragment>
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading...' : 'Load More'}
      </button>
    </>
  )
}

Offset-Based Pagination with placeholderData:

import { keepPreviousData } from '@tanstack/react-query'

function Posts() {
  const [page, setPage] = useState(0)

  const { data, isPending, isPlaceholderData } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPosts(page),
    placeholderData: keepPreviousData, // Show previous data while fetching
  })

  return (
    <>
      {data.posts.map(post => <PostCard key={post.id} {...post} />)}

      <button
        onClick={() => setPage(p => Math.max(0, p - 1))}
        disabled={page === 0}
      >
        Previous
      </button>

      <button
        onClick={() => setPage(p => p + 1)}
        disabled={isPlaceholderData || !data.hasMore}
      >
        Next
      </button>
    </>
  )
}

Dependent Queries - Sequential data fetching:

function UserProjects({ email }: { email: string }) {
  // First query
  const { data: user } = useQuery({
    queryKey: ['user', email],
    queryFn: () => getUserByEmail(email),
  })

  // Second query waits for first
  const { data: projects } = useQuery({
    queryKey: ['projects', user?.id],
    queryFn: () => getProjectsByUser(user.id),
    enabled: !!user?.id, // Only runs when user.id exists
  })

  return <div>{/* render projects */}</div>
}

Performance Optimization

staleTime is your primary control - adjust this, not gcTime:

// Real-time data (default)
staleTime: 0 // Always considered stale, refetch on mount

// User profiles (changes infrequently)
staleTime: 1000 * 60 * 2 // Fresh for 2 minutes

// Static reference data
staleTime: 1000 * 60 * 10 // Fresh for 10 minutes

Query deduplication happens automatically - multiple components mounting with identical query keys result in a single network request, but all components receive data.

Prevent request waterfalls:

// ❌ Waterfall - each query waits for previous
function Dashboard() {
  const { data: user } = useQuery(userQuery)
  const { data: posts } = useQuery(postsQuery(user?.id))
  const { data: stats } = useQuery(statsQuery(user?.id))
}

// ✅ Parallel - all queries start simultaneously
function Dashboard() {
  const { data: user } = useQuery(userQuery)
  const { data: posts } = useQuery({
    ...postsQuery(user?.id),
    enabled: !!user?.id,
  })
  const { data: stats } = useQuery({
    ...statsQuery(user?.id),
    enabled: !!user?.id,
  })
}

// ✅ Best - prefetch in route loader (see Router × Query Integration)

Never copy server state to local state - this opts out of background updates:

// ❌ Wrong - copies to state, loses reactivity
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
const [todos, setTodos] = useState(data)

// ✅ Correct - use query data directly
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

Testing with Mock Service Worker (MSW)

MSW is the recommended approach - mock the network layer:

// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/todos', () => {
    return HttpResponse.json([
      { id: 1, text: 'Test todo', completed: false },
    ])
  }),

  http.post('/api/todos', async ({ request }) => {
    const newTodo = await request.json()
    return HttpResponse.json({ id: 2, ...newTodo })
  }),
]

// src/test/setup.ts
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'

export const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

Create test wrappers with proper QueryClient:

// src/test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'

export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false, // Prevent retries in tests
        gcTime: Infinity,
      },
    },
  })
}

export function renderWithClient(ui: React.ReactElement) {
  const testQueryClient = createTestQueryClient()

  return render(
    <QueryClientProvider client={testQueryClient}>
      {ui}
    </QueryClientProvider>
  )
}

Test queries:

import { renderWithClient } from '@/test/utils'
import { screen } from '@testing-library/react'

test('displays todos', async () => {
  renderWithClient(<TodoList />)

  // Wait for data to load
  expect(await screen.findByText('Test todo')).toBeInTheDocument()
})

test('shows error state', async () => {
  // Override handler for this test
  server.use(
    http.get('/api/todos', () => {
      return HttpResponse.json(
        { message: 'Failed to fetch' },
        { status: 500 }
      )
    })
  )

  renderWithClient(<TodoList />)

  expect(await screen.findByText(/failed/i)).toBeInTheDocument()
})

Critical testing principles:

  • Create new QueryClient per test for isolation
  • Set retry: false to prevent timeouts
  • Use async queries (findBy*) for data that loads
  • Silence console.error for expected errors

Anti-Patterns to Avoid

❌ Don't store query data in Redux/Context:

  • Creates dual sources of truth
  • Loses automatic cache invalidation
  • Triggers unnecessary renders

❌ Don't call refetch() with different parameters:

// ❌ Wrong - breaks declarative pattern
const { data, refetch } = useQuery({
  queryKey: ['todos'],
  queryFn: () => fetchTodos(filters),
})
// Later: refetch with different filters??? Won't work!

// ✅ Correct - include params in key
const [filters, setFilters] = useState('all')
const { data } = useQuery({
  queryKey: ['todos', filters],
  queryFn: () => fetchTodos(filters),
})
// Changing filters automatically refetches

❌ Don't use queries for local state:

  • Query Cache expects refetchable data
  • Use useState/useReducer for client-only state

❌ Don't create QueryClient inside components:

// ❌ Wrong - new cache every render
function App() {
  const client = new QueryClient()
  return <QueryClientProvider client={client}>...</QueryClientProvider>
}

// ✅ Correct - stable instance
const queryClient = new QueryClient()
function App() {
  return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}

❌ Don't ignore loading and error states - always handle both

❌ Don't transform data by copying to state - use select option

❌ Don't mismatch query keys - be consistent with types ('1' vs 1)

Cache Timing Guidelines

staleTime - How long data is considered fresh:

  • 0 (default) - Always stale, refetch on mount/focus
  • 30_000 (30s) - Good for user-generated content
  • 120_000 (2min) - Good for profile data
  • 600_000 (10min) - Good for static reference data

gcTime (formerly cacheTime) - How long unused data stays in cache:

  • 300_000 (5min, default) - Good for most cases
  • Infinity - Keep forever (useful with persistence)
  • 0 - Immediate garbage collection (not recommended)

Relationship: staleTime controls refetch frequency, gcTime controls memory cleanup.

Related Skills

  • router-query-integration - Integrating Query with TanStack Router loaders
  • api-integration - Apidog + OpenAPI integration
  • react-patterns - Choose between Query mutations vs React Actions
  • testing-strategy - Advanced MSW patterns