tanstack-query

MadAppGang's avatarfrom MadAppGang

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.

193stars🔀17forks📁View on GitHub🕐Updated Dec 30, 2025

When & Why to Use This Skill

This Claude skill provides a comprehensive framework and production-ready patterns for managing server state using TanStack Query v5. It enables developers to implement robust data fetching, efficient caching, and complex synchronization logic while adhering to modern React best practices and TypeScript type safety.

Use Cases

  • 1. Advanced Server State Management: Implementing hierarchical query keys and centralized query factories to manage complex data dependencies and cache invalidation.
  • 2. High-Performance UI Development: Utilizing optimistic updates and prefetching strategies to create near-instantaneous user experiences even during network latency.
  • 3. Type-Safe API Integration: Leveraging TypeScript inference and the queryOptions API to ensure end-to-end type safety from the network layer to the component level.
  • 4. Reliable Integration Testing: Setting up standardized testing environments using Mock Service Worker (MSW) to simulate real-world API interactions and error states.
  • 5. Modernization & Migration: Guiding the transition from older React Query versions to v5, including handling breaking changes like the single object signature and Suspense integration.
nametanstack-query
descriptionUse when managing server state with TanStack Query v5. Covers query key factories, data transformation, mutations, optimistic updates, authentication, testing with MSW, and best practices for async state management.
updated2026-01-20
keywordstanstack-query, react-query, server-state, data-fetching, cache, mutations, msw

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