# TanStack Query (React Query) - Complete Agent Guide This document contains the full compiled guide for AI coding agents working with TanStack Query. Based on TkDodo's authoritative blog posts. --- ## Core Mental Model ### React Query is NOT a Data Fetching Library React Query doesn't fetch data - it's agnostic about HOW you fetch. It only needs a Promise that resolves or rejects. Handle baseURLs, headers, GraphQL, etc. in your data layer. **What React Query IS**: An async state manager that handles: - Automatic staleness tracking - Lifecycle management (loading, error states) - Continuous synchronization without manual intervention ### Server State vs Client State - **Server state**: A snapshot you don't own - other users can modify it - **Client state**: Synchronous state you control (dark mode, UI toggles) **Critical Rule**: Never sync React Query data into Redux or local state via useEffect. Call useQuery wherever you need the data. ### Understanding staleTime and gcTime - `staleTime: 0` (default) - Data is immediately stale (eligible for background refetch) - `gcTime` (formerly `cacheTime`) - How long inactive data stays in cache before garbage collection **Key insight**: "As long as data is fresh, it will always come from the cache only." --- ## Query Keys ### Rules 1. **Always use arrays**: `['todos']` not `'todos'` 2. **Structure generic to specific**: `['todos', 'list', { filters }]` 3. **Include all dependencies**: Every variable that affects the data 4. **Keys must match exactly**: `['item', '1']` !== `['item', 1]` ### Query Key Factory Pattern ```typescript const todoKeys = { all: ['todos'] as const, lists: () => [...todoKeys.all, 'list'] as const, list: (filters: Filters) => [...todoKeys.lists(), { filters }] as const, details: () => [...todoKeys.all, 'detail'] as const, detail: (id: number) => [...todoKeys.details(), id] as const, } // Usage queryClient.invalidateQueries({ queryKey: todoKeys.all }) // all todos queryClient.invalidateQueries({ queryKey: todoKeys.lists() }) // only lists useQuery({ queryKey: todoKeys.detail(5), queryFn: () => fetchTodo(5) }) ``` --- ## Status Handling ### Data-First Pattern (Recommended) ```typescript const query = useTodosQuery() // Check data first - preserves cached data during background errors if (query.data) { return } if (query.error) { return } return ``` ### Why Not Status-First ```typescript // Anti-pattern: hides cached data on background refetch errors if (query.isPending) return if (query.isError) return return ``` With stale-while-revalidate, you often have both stale data AND an error. Don't hide valid cached content. ### fetchStatus vs status - `status`: Data state (`success`, `pending`, `error`) - `fetchStatus`: Request state (`fetching`, `paused`, `idle`) A query can be `success` and `paused` simultaneously (has data, but offline). --- ## Mutations ### Invalidation vs Direct Updates ```typescript // Invalidation (safer, recommended for most cases) useMutation({ mutationFn: updateTodo, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }) }, }) // Direct update (instant, use when mutation returns complete data) useMutation({ mutationFn: updateTodo, onSuccess: (data) => { queryClient.setQueryData(['todos', data.id], data) }, }) ``` ### Return Promises for Loading State ```typescript // Correct: mutation stays loading during invalidation onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }) // Wrong: mutation completes immediately onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }) } ``` ### Optimistic Updates Pattern ```typescript useMutation({ mutationFn: updateTodo, onMutate: async (newTodo) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] }) // Snapshot previous value const previousTodo = queryClient.getQueryData(['todos', newTodo.id]) // Optimistically update queryClient.setQueryData(['todos', newTodo.id], newTodo) return { previousTodo } }, onError: (err, newTodo, context) => { // Rollback on error queryClient.setQueryData(['todos', newTodo.id], context.previousTodo) }, onSettled: () => { // Always refetch after error or success queryClient.invalidateQueries({ queryKey: ['todos'] }) }, }) ``` ### When to Use Optimistic Updates - High-confidence operations (toggles, likes) - NOT for navigational mutations where rollbacks create poor UX ### Mutation Rules 1. **Prefer mutate() over mutateAsync()** - Avoids manual error handling 2. **Single argument** - Use objects for multiple values 3. **Callback placement** - Query logic in useMutation; UI actions in mutate() --- ## TypeScript Integration ### Prefer Type Inference ```typescript // Add explicit return types to API functions function fetchTodos(): Promise { return axios.get('/todos').then(res => res.data) } // Types are inferred automatically function useTodos() { return useQuery({ queryKey: ['todos'], queryFn: fetchTodos, }) } ``` ### Don't Destructure for Type Narrowing ```typescript // Correct: type narrowing works const query = useTodos() if (query.isSuccess) { query.data // Type: Todo[] (not undefined) } // Wrong: breaks type narrowing const { data, isSuccess } = useTodos() if (isSuccess) { data // Type: Todo[] | undefined (still includes undefined) } ``` ### queryOptions Helper (v5+) ```typescript const todosQuery = queryOptions({ queryKey: ['todos'], queryFn: fetchTodos, }) // Reusable and fully type-safe useQuery(todosQuery) queryClient.prefetchQuery(todosQuery) const data = queryClient.getQueryData(todosQuery.queryKey) // Type: Todo[] | undefined ``` ### Type-Safe Factories ```typescript const todoQueries = { all: () => ['todos'] as const, lists: () => [...todoQueries.all(), 'list'] as const, list: (filters: Filters) => queryOptions({ queryKey: [...todoQueries.lists(), filters], queryFn: () => fetchTodos(filters), }), detail: (id: number) => queryOptions({ queryKey: ['todos', 'detail', id], queryFn: () => fetchTodo(id), }), } ``` --- ## Cache Management ### placeholderData vs initialData | Aspect | placeholderData | initialData | |--------|-----------------|-------------| | Level | Observer | Cache | | Persistence | Never cached | Persisted | | Refetch | Always triggers | Respects staleTime | | On error | Becomes undefined | Persists | | Use case | "Fake" estimated data | "Real" data from cache | ### Seeding from Existing Cache ```typescript // Pull approach: at detail render time useQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id), initialData: () => { return queryClient.getQueryData(['todos'])?.find(t => t.id === id) }, initialDataUpdatedAt: () => { return queryClient.getQueryState(['todos'])?.dataUpdatedAt }, }) // Push approach: after fetching list const fetchTodos = async () => { const todos = await api.getTodos() todos.forEach(todo => { queryClient.setQueryData(['todo', todo.id], todo) }) return todos } ``` --- ## Error Handling ### Global Error Callbacks (Background Refetch Toasts) ```typescript const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { // Only toast for background failures (has existing data) if (query.state.data !== undefined) { toast.error(`Update failed: ${error.message}`) } }, }), }) ``` ### Error Boundaries ```typescript useQuery({ queryKey: ['todos'], queryFn: fetchTodos, throwOnError: true, // All errors go to boundary }) // Granular: only 5xx errors to boundary useQuery({ queryKey: ['todos'], queryFn: fetchTodos, throwOnError: (error) => error.response?.status >= 500, }) ``` ### Fetch API Gotcha ```typescript // Fetch doesn't reject on 4xx/5xx - check manually const fetchTodos = async () => { const response = await fetch('/todos') if (!response.ok) { throw new Error('Network response was not ok') } return response.json() } ``` --- ## Render Optimization ### Select for Computed Data ```typescript // Only re-renders when todoCount changes const { data: todoCount } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select: (data) => data.length, }) // Only re-renders when specific todo changes const { data: todo } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select: (data) => data.find(t => t.id === 5), }) ``` ### Tracked Queries (Default v4+) Components only re-render when properties they use change. Caveat: spreading `{ ...query }` tracks all fields. ### Structural Sharing React Query preserves object references for unchanged data. Disable for large datasets: ```typescript useQuery({ queryKey: ['largeData'], queryFn: fetchLargeData, structuralSharing: false, }) ``` --- ## Testing ### Setup Pattern ```typescript const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, // Critical! }, }, }) return ({ children }) => ( {children} ) } // Each test gets fresh client it('fetches todos', async () => { const { result } = renderHook(() => useTodos(), { wrapper: createWrapper(), }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) }) ``` ### Use Mock Service Worker (MSW) MSW is recommended for network mocking - single source of truth for all environments. --- ## Advanced Patterns ### WebSocket Integration ```typescript useEffect(() => { const ws = new WebSocket(url) ws.onmessage = (event) => { const data = JSON.parse(event.data) queryClient.invalidateQueries({ queryKey: [...data.entity, data.id].filter(Boolean) }) } return () => ws.close() }, []) // With WebSockets handling updates, set high staleTime const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }) ``` ### React Router Integration ```typescript // Loader export const loader = (queryClient: QueryClient) => async ({ params }: LoaderFunctionArgs) => { const query = todoQuery(params.id!) return queryClient.getQueryData(query.queryKey) ?? await queryClient.fetchQuery(query) } // Component function TodoPage() { const initialData = useLoaderData() as Todo const params = useParams() const { data } = useQuery({ ...todoQuery(params.id!), initialData, }) } ``` ### Suspense Queries (v5+) ```typescript // Guaranteed data - no undefined function TodoList() { const { data } = useSuspenseQuery({ queryKey: ['todos'], queryFn: fetchTodos, }) // data is Todo[], not Todo[] | undefined return } ``` --- ## Anti-Patterns to Avoid ### Don't sync to local state ```typescript // WRONG const { data } = useQuery({...}) const [localData, setLocalData] = useState(data) useEffect(() => setLocalData(data), [data]) // CORRECT const { data } = useQuery({...}) // Use data directly ``` ### Don't use QueryCache as state manager `setQueryData` is for optimistic updates and mutation responses only. ### Don't create unstable QueryClient ```typescript // WRONG function App() { const queryClient = new QueryClient() // Recreates every render! return ... } // CORRECT const queryClient = new QueryClient() function App() { return ... } ``` ### Don't swallow errors ```typescript // WRONG const fetchData = async () => { try { return await api.get() } catch (error) { console.log(error) // Query thinks this succeeded! } } // CORRECT const fetchData = async () => { try { return await api.get() } catch (error) { console.log(error) throw error // Re-throw! } } ``` --- ## When NOT to Use React Query 1. **React Server Components** - Use framework-native data fetching 2. **Next.js/Remix simple needs** - Built-in solutions may suffice 3. **GraphQL with normalized cache** - Consider Apollo Client or urql 4. **No background updates needed** - Static SSR may be enough --- ## Resources - Official docs: https://tanstack.com/query - TkDodo's blog: https://tkdodo.eu/blog