tanstack-query

TanStack Query (React Query) v5 best practices for data fetching, caching, and mutations. Use when fetching server data with useQuery, setting up mutations with useMutation, configuring caching strategies, managing query keys, handling optimistic updates, invalidating queries, or integrating TanStack Query into a React/TypeScript project. Triggers on "React Query", "TanStack Query", "useQuery", "useMutation", "query keys", "stale time", "cache invalidation", "optimistic update", "prefetch", "infinite query".

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "tanstack-query" with this command: npx skills add kbravh/skills/kbravh-skills-tanstack-query

TanStack Query Best Practices

Patterns and conventions for TanStack Query v5 data fetching, caching, and mutations in React/TypeScript projects.

Core Mental Model

  • Server state vs client state — TanStack Query manages server state (data from APIs). Do not duplicate it into useState or Redux; use those only for client state (UI state, form drafts).
  • Stale-while-revalidate — Cached data is served immediately, then validated in the background. Users see data fast; freshness happens transparently.
  • Query keys = dependency arrays — Every variable that affects fetching belongs in the key. When the key changes, TanStack Query refetches automatically.
  • Declarative over imperative — Drive refetching by changing key values (state, filters, IDs), not by calling refetch() with new parameters.
  • Cache is the single source of truth — The query cache is the authoritative store for all server state. Read from it; never copy it elsewhere.

Quick Setup

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60, // 1 minute — adjust per project
    },
  },
})

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

Set staleTime globally instead of disabling individual refetch flags (refetchOnMount, refetchOnWindowFocus). Those flags are features — staleTime controls when they activate.

Important: Create the QueryClient outside the component body, or stabilize it with useState:

// Inside a component — use useState to prevent new cache on every render
const [queryClient] = useState(() => new QueryClient({ /* ... */ }))

Custom Hooks + queryOptions

Always wrap queries in custom hooks. Use queryOptions to co-locate key + function in a reusable, type-safe object:

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

function todosQueryOptions(filters: TodoFilters) {
  return queryOptions({
    queryKey: ['todos', 'list', filters],
    queryFn: () => fetchTodos(filters),
  })
}

export function useTodos(filters: TodoFilters) {
  return useQuery(todosQueryOptions(filters))
}

queryOptions enables reuse across useQuery, useSuspenseQuery, prefetchQuery, ensureQueryData, and invalidateQueries — all from a single definition.

Query Key Factories

Structure keys hierarchically from general to specific. Use queryOptions at fetchable levels, plain arrays at invalidation-only levels:

export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: TodoFilters) =>
    queryOptions({
      queryKey: [...todoKeys.lists(), filters] as const,
      queryFn: () => fetchTodos(filters),
    }),
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) =>
    queryOptions({
      queryKey: [...todoKeys.details(), id] as const,
      queryFn: () => fetchTodo(id),
    }),
}

Invalidation at different granularities:

// All todos (lists + details)
queryClient.invalidateQueries({ queryKey: todoKeys.all })

// All lists (any filter)
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })

// One specific list
queryClient.invalidateQueries({ queryKey: todoKeys.list(filters).queryKey })

// All details
queryClient.invalidateQueries({ queryKey: todoKeys.details() })

// One specific detail
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id).queryKey })

Co-locate key factories with their feature, not in a global queryKeys.ts.

Dependent Queries

Two patterns for queries that depend on a value that may not exist yet:

skipToken (preferred for TypeScript):

import { skipToken, useQuery } from '@tanstack/react-query'

export function useUser(userId: number | undefined) {
  return useQuery({
    queryKey: ['users', userId],
    queryFn: userId ? () => fetchUser(userId) : skipToken,
  })
}

enabled option:

export function useUser(userId: number | undefined) {
  return useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId!),
    enabled: !!userId,
  })
}

skipToken is better because TypeScript narrows userId inside the truthy branch — no non-null assertion needed.

Data Transformations with select

Use select for derived data and partial subscriptions. Components only re-render when the selected value changes:

// Only re-renders when the count changes
export function useTodoCount(filters: TodoFilters) {
  return useQuery({
    ...todosQueryOptions(filters),
    select: (data) => data.length,
  })
}

Stabilize references — inline select functions create new references every render:

// Extract outside component (no deps)
const selectTodoCount = (data: Todo[]) => data.length

// Or wrap in useCallback (with deps)
const select = useCallback(
  (data: Todo[]) => data.filter((t) => t.priority >= minPriority),
  [minPriority],
)

Status Checks: Data First

Check data before error to avoid replacing stale data with an error screen during background refetches:

const todos = useTodos(filters)

if (todos.data) return <TodoList todos={todos.data} />
if (todos.error) return <ErrorMessage error={todos.error} />
return <Loading />

Background refetch failures set error while stale data remains available. Checking error first hides perfectly good cached data.

Mutations

Use useMutation + query invalidation as the default strategy:

import { useMutation, useQueryClient } from '@tanstack/react-query'

export function useAddTodo() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: (newTodo: NewTodo) => createTodo(newTodo),
    onSuccess: () => {
      return queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
    },
  })
}

Return the promise from invalidateQueries in onSuccess to keep the mutation in loading state until the refetch completes.

Mutation strategy decision table

StrategyWhenComplexity
InvalidationDefault for all mutationsLow
Direct cache updateResponse returns full updated entityMedium
Optimistic updateInstant feedback essential, rarely failsHigh — see optimistic-updates.md

Callback separation

Place query/cache logic in useMutation callbacks and UI logic in mutate() callbacks:

// Hook — cache concerns
const mutation = useMutation({
  mutationFn: updateTodo,
  onSuccess: () => {
    return queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
  },
})

// Component — UI concerns
mutation.mutate(updatedTodo, {
  onSuccess: () => {
    toast.success('Saved!')
    navigate('/todos')
  },
})

Prefer mutate over mutateAsync. mutate handles errors automatically. Only use mutateAsync when composing multiple dependent mutations.

Pass multiple variables as a single objectmutate accepts only one argument:

mutation.mutate({ id: 1, title: 'Updated' }) // correct

TypeScript

  • Let inference work. Type the queryFn return, not useQuery generics. Avoid useQuery<Todo>().

  • Validate at the boundary. Use Zod in queryFn for runtime type safety:

    const todoSchema = z.object({ id: z.number(), title: z.string(), done: z.boolean() })
    
    const fetchTodo = async (id: number) => {
      const res = await fetch(`/api/todos/${id}`)
      if (!res.ok) throw new Error('Failed to fetch')
      return todoSchema.parse(await res.json())
    }
    
  • Use skipToken for type-safe dependent queries (narrows types without !).

  • Register global error types via module augmentation:

    declare module '@tanstack/react-query' {
      interface Register {
        defaultError: AxiosError
      }
    }
    
  • Don't destructure if you need type narrowing — const query = useTodos() then if (query.isSuccess) narrows query.data.

Error Handling

Three complementary strategies:

1. Per-query error property (inline)

if (query.error) return <ErrorMessage error={query.error} />

2. Error boundaries with throwOnError

Use a function for granular control — propagate 5xx to boundaries, handle 4xx locally:

useQuery({
  ...todosQueryOptions(filters),
  throwOnError: (error) => error.status >= 500,
})

3. Global toasts via QueryCache.onError

Show toasts only for background refetch failures (stale data exists):

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (query.state.data !== undefined) {
        toast.error(`Background update failed: ${error.message}`)
      }
    },
  }),
})

Fetch API gotcha

fetch does not reject on 4xx/5xx. You must throw manually:

const res = await fetch(url)
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
return res.json()

Anti-Patterns

1. Copying server state to useState

// Don't
const { data } = useTodos()
const [todos, setTodos] = useState(data) // stale copy, never updates

// Do — use query data directly
const { data: todos } = useTodos()

2. Imperative refetching in useEffect

// Don't
useEffect(() => { refetch() }, [filters])

// Do — put filters in the query key
useQuery({ queryKey: ['todos', filters], queryFn: () => fetchTodos(filters) })

3. Disabling refetch flags

// Don't
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, refetchOnWindowFocus: false, refetchOnMount: false })

// Do — set staleTime to control freshness
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 1000 * 60 * 5 })

4. Using query cache as local state manager

Don't call setQueryData for client-only state. Background refetches overwrite manual cache writes.

5. Object rest destructuring

// Don't — defeats tracked queries, re-renders on every field change
const { data, ...rest } = useQuery(...)

// Do — destructure only what you need
const { data, isPending } = useQuery(...)

6. Inline selectors without stable references

// Don't — new function every render
useQuery({ ...opts, select: (d) => d.filter(expensiveCheck) })

// Do — extract or useCallback
const select = useCallback((d: Todo[]) => d.filter(expensiveCheck), [])

7. Manual generic specification on useQuery

// Don't
useQuery<Todo[], Error, Todo[], ['todos']>({ ... })

// Do — let inference flow from queryFn return type
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

8. Checking error before data

// Don't — hides cached data on background refetch error
if (error) return <Error />
if (data) return <Data />

// Do — show cached data even when background refetch fails
if (data) return <Data />
if (error) return <Error />

Configuration Reference

OptionDefaultPurpose
staleTime0How long data is considered fresh (ms). Fresh data is never refetched.
gcTime5 minHow long inactive cache entries are kept before garbage collection.
retry3Number of retry attempts on failure. Set false in tests.
enabledtrueSet false to disable automatic fetching. Prefer skipToken.
refetchOnWindowFocustrueRefetch stale queries on window focus. Controlled by staleTime.
throwOnErrorfalsePropagate errors to nearest error boundary. Accepts a function.
networkMode'online''online' / 'always' / 'offlineFirst'. Controls fetch behavior when offline.
placeholderDataShown while real data loads. Use keepPreviousData for pagination.

Specialized Guides

Open these only when the task requires them:

  • Optimistic Updates — When implementing optimistic UI, direct cache manipulation, or concurrent mutations
  • Testing — When writing tests for queries, mutations, or components using TanStack Query
  • Suspense & SSR — When using useSuspenseQuery, prefetching in loaders, or server-side rendering
  • Realtime & Advanced Patterns — When integrating WebSockets, infinite scrolling, offline support, or form state management

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

tanstack-query

No summary provided by upstream source.

Repository SourceNeeds Review
General

svg-logo-creator

No summary provided by upstream source.

Repository SourceNeeds Review
General

logo-ideation

No summary provided by upstream source.

Repository SourceNeeds Review
General

tanstack-query

No summary provided by upstream source.

Repository SourceNeeds Review