TanStack Query logo

tanstack query

Server state management for React applications

$ npx docs2skills add tanstack-query-react
SKILL.md

TanStack Query

Server state management for React applications

What this skill does

TanStack Query is the missing data-fetching library for React applications. It transforms server state management by providing intelligent caching, background synchronization, and automatic updates. Unlike client state libraries (Redux, Zustand), TanStack Query specializes in async server state that's persisted remotely and can become stale.

It eliminates the need for manual cache management, loading states, and request deduplication. Out of the box, it provides background refetching, stale-while-revalidate patterns, optimistic updates, and memory management. This dramatically reduces boilerplate code while making apps feel faster and more responsive.

TanStack Query handles the hardest problems in data fetching: caching strategies, request deduplication, background updates, knowing when data is stale, pagination, infinite scrolling, and memory optimization.

Prerequisites

  • React 16.8+ (hooks support)
  • TypeScript 4.1+ (if using TypeScript)
  • Modern bundler (Vite, Webpack, etc.)

Quick start

npm install @tanstack/react-query
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'

const queryClient = new QueryClient()

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Posts />
    </QueryClientProvider>
  )
}

function Posts() {
  const { data, isPending, error } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(res => res.json())
  })

  if (isPending) return 'Loading...'
  if (error) return 'Error: ' + error.message
  return <div>{data.map(post => <div key={post.id}>{post.title}</div>)}</div>
}

Core concepts

Query Keys: Unique identifiers that describe data dependencies. Arrays that can include strings, numbers, objects. Changes to query keys trigger refetches.

Query Functions: Async functions that return data or throw errors. Must be deterministic for a given query key.

Cache: In-memory storage with intelligent invalidation, garbage collection, and structural sharing for performance.

Background Refetching: Automatic data updates when window regains focus, network reconnects, or at intervals.

Stale-while-revalidate: Serves cached data immediately while fetching fresh data in the background.

Optimistic Updates: Update UI immediately, rollback on failure.

Key API surface

Queries

  • useQuery({ queryKey, queryFn, ...options }) - Basic data fetching
  • useQueries([{ queryKey, queryFn }, ...]) - Multiple queries in parallel
  • useInfiniteQuery({ queryKey, queryFn, getNextPageParam }) - Pagination/infinite scroll
  • useSuspenseQuery({ queryKey, queryFn }) - Suspense integration

Mutations

  • useMutation({ mutationFn, onSuccess, onError }) - Data modifications
  • useMutationState() - Access mutation states globally

Client Operations

  • useQueryClient() - Access query client instance
  • queryClient.invalidateQueries({ queryKey }) - Force refetch
  • queryClient.setQueryData(queryKey, data) - Manual cache updates
  • queryClient.prefetchQuery({ queryKey, queryFn }) - Preload data

Status Hooks

  • useIsFetching() - Global loading indicator
  • useIsMutating() - Global mutation indicator

Options Factories

  • queryOptions({ queryKey, queryFn }) - Reusable query configurations
  • infiniteQueryOptions() - Reusable infinite query configs

Common patterns

Basic CRUD operations

function Posts() {
  const queryClient = useQueryClient()
  
  const { data: posts } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(res => res.json())
  })

  const createPost = useMutation({
    mutationFn: (newPost) => fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(newPost)
    }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    }
  })

  const deletePost = useMutation({
    mutationFn: (id) => fetch(`/api/posts/${id}`, { method: 'DELETE' }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    }
  })
}

Dependent queries

function UserPosts({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
  })

  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetch(`/api/posts?userId=${userId}`).then(res => res.json()),
    enabled: !!user
  })
}

Infinite scrolling

function InfinitePostsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 0 }) =>
      fetch(`/api/posts?page=${pageParam}`).then(res => res.json()),
    getNextPageParam: (lastPage) => lastPage.nextPage,
    initialPageParam: 0
  })

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => <div key={post.id}>{post.title}</div>)}
        </div>
      ))}
      <button 
        onClick={fetchNextPage}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading...' : 'Load More'}
      </button>
    </div>
  )
}

Optimistic updates

function TodoItem({ todo }) {
  const queryClient = useQueryClient()

  const updateTodo = useMutation({
    mutationFn: (updates) => fetch(`/api/todos/${todo.id}`, {
      method: 'PATCH',
      body: JSON.stringify(updates)
    }),
    onMutate: async (updates) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] })
      const previousTodos = queryClient.getQueryData(['todos'])
      
      queryClient.setQueryData(['todos'], old =>
        old.map(t => t.id === todo.id ? { ...t, ...updates } : t)
      )
      
      return { previousTodos }
    },
    onError: (err, updates, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos)
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    }
  })
}

Query options pattern

const postQueries = {
  all: () => ({ queryKey: ['posts'] }),
  lists: () => ({ queryKey: ['posts', 'list'] }),
  list: (filters) => ({ 
    queryKey: ['posts', 'list', filters],
    queryFn: () => fetchPosts(filters)
  }),
  details: () => ({ queryKey: ['posts', 'detail'] }),
  detail: (id) => ({ 
    queryKey: ['posts', 'detail', id],
    queryFn: () => fetchPost(id)
  })
}

function Posts({ filters }) {
  const { data } = useQuery(postQueries.list(filters))
}

Configuration

Query client defaults

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      gcTime: 10 * 60 * 1000,   // 10 minutes (was cacheTime)
      retry: 3,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true
    },
    mutations: {
      retry: 1
    }
  }
})

Per-query configuration

const { data } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 10 * 60 * 1000,     // Don't refetch for 10 minutes
  gcTime: 15 * 60 * 1000,        // Keep in cache for 15 minutes
  retry: (failureCount, error) => {
    if (error.status === 404) return false
    return failureCount < 3
  },
  refetchInterval: 30000,         // Poll every 30 seconds
  enabled: !!userId               // Conditional fetching
})

Best practices

  1. Use query keys consistently: Keep them hierarchical and descriptive. ['posts', 'list', filters] over ['posts'].

  2. Colocate query definitions: Create query factories to ensure consistent keys and functions across components.

  3. Handle loading and error states: Always check isPending and error before rendering data.

  4. Invalidate related queries: After mutations, invalidate all potentially affected query keys.

  5. Use enabled option for dependent queries: Prevent unnecessary requests when dependencies aren't ready.

  6. Optimize with select: Transform data at the query level to prevent unnecessary re-renders.

  7. Prefetch for better UX: Load data before users need it using queryClient.prefetchQuery().

  8. Structure mutations properly: Use onMutate for optimistic updates, onError for rollbacks, onSettled for cleanup.

  9. Configure appropriate stale times: Set longer staleTime for rarely-changing data to reduce requests.

  10. Use Suspense boundaries: Wrap useSuspenseQuery components in error boundaries for better error handling.

Gotchas and common mistakes

Query keys must be stable: Objects in query keys should be serializable and ordered consistently. ['posts', { userId: 1 }] works, but functions don't.

Don't destructure with rest operator: const { data, ...rest } = useQuery() can cause performance issues. Destructure only what you need.

Mutations don't automatically update queries: You must invalidate or update the cache manually after successful mutations.

Window focus refetching is enabled by default: This can cause unexpected requests. Disable with refetchOnWindowFocus: false if not desired.

gcTime vs staleTime confusion: staleTime controls when data is considered fresh, gcTime controls when unused data is garbage collected.

Infinite queries need proper page param handling: getNextPageParam must return undefined when there are no more pages.

Query functions must be pure: Same inputs should always produce the same request. Side effects belong in onSuccess/onError.

Suspense queries throw promises: Must be wrapped in Suspense boundaries and error boundaries.

Parallel mutations can cause race conditions: Use await queryClient.cancelQueries() in onMutate to prevent conflicts.

Query client must be stable: Don't create new QueryClient instances on every render. Create once and reuse.

Structural sharing prevents some optimizations: If you need referential equality, use select to return stable references.

Background refetching happens during interactions: Users might see loading indicators during typing. Consider refetchOnWindowFocus: false for forms.

Query errors are thrown in Suspense mode: Error boundaries become critical for user experience.

Initial data bypasses cache: Using initialData prevents background refetching until data becomes stale.