tanstack-query

TanStack Query (React Query) Skill

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 bobmatnyc/claude-mpm-skills/bobmatnyc-claude-mpm-skills-tanstack-query

TanStack Query (React Query) Skill

Summary

TanStack Query (formerly React Query) is a powerful asynchronous state management library for React that handles server-state fetching, caching, synchronization, and updates. It eliminates the need for manual data fetching boilerplate and provides built-in features like background refetching, optimistic updates, pagination, and intelligent cache management.

When to Use

Use TanStack Query when:

  • Fetching data from REST APIs, GraphQL, or tRPC endpoints

  • Need automatic background refetching and cache invalidation

  • Building real-time dashboards with polling or websocket data

  • Implementing infinite scroll or pagination

  • Require optimistic UI updates for mutations

  • Managing complex server-state synchronization

  • Need offline support with cache persistence

  • Building applications with frequent data updates

TanStack Query excels at:

  • Server-state management (API data, external state)

  • Request deduplication and caching

  • Stale-while-revalidate patterns

  • Loading and error state management

  • Prefetching and eager loading

  • Parallel and dependent query orchestration

Avoid TanStack Query for:

  • Pure client-side state (use Zustand, Jotai, or Context)

  • Form state management (use React Hook Form, Formik)

  • Simple one-time fetches without caching needs

Quick Start

Installation

npm install @tanstack/react-query

DevTools (optional but recommended)

npm install @tanstack/react-query-devtools

Basic Setup

// app/providers.tsx 'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 minute refetchOnWindowFocus: false, }, }, }));

return ( <QueryClientProvider client={queryClient}> {children} <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> ); }

First Query

// components/UserProfile.tsx import { useQuery } from '@tanstack/react-query';

interface User { id: number; name: string; email: string; }

function UserProfile({ userId }: { userId: number }) { const { data, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: async () => { const response = await fetch(/api/users/${userId}); if (!response.ok) throw new Error('Failed to fetch user'); return response.json() as Promise<User>; }, });

if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>;

return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> </div> ); }

First Mutation

// components/CreateUserForm.tsx import { useMutation, useQueryClient } from '@tanstack/react-query';

function CreateUserForm() { const queryClient = useQueryClient();

const mutation = useMutation({ mutationFn: async (newUser: { name: string; email: string }) => { const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newUser), }); return response.json(); }, onSuccess: () => { // Invalidate and refetch users list queryClient.invalidateQueries({ queryKey: ['users'] }); }, });

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); mutation.mutate({ name: formData.get('name') as string, email: formData.get('email') as string, }); };

return ( <form onSubmit={handleSubmit}> <input name="name" placeholder="Name" required /> <input name="email" type="email" placeholder="Email" required /> <button type="submit" disabled={mutation.isPending}> {mutation.isPending ? 'Creating...' : 'Create User'} </button> {mutation.isError && <p>Error: {mutation.error.message}</p>} </form> ); }

Core Concepts

Server State vs Client State

Server State Characteristics:

  • Persisted remotely (database, API, cloud)

  • Requires asynchronous APIs for fetching/updating

  • Can be out of sync with client

  • Can be updated by other users/systems

  • Examples: User data, posts, products, settings

Client State Characteristics:

  • Persisted locally (memory, localStorage)

  • Synchronously accessible

  • Fully controlled by client

  • Examples: UI theme, modal open/closed, form inputs

TanStack Query manages server state. Use Zustand/Context for client state.

Query Keys

Query keys uniquely identify queries and their cached data.

Key Structure:

// String key (simple) queryKey: ['todos']

// Array key (recommended for dependencies) queryKey: ['todo', todoId] queryKey: ['todos', { status: 'active', page: 1 }]

// Nested arrays (complex hierarchies) queryKey: ['users', userId, 'posts', { sort: 'date' }]

Key Matching:

// Exact match queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true });

// Prefix match (invalidates all matching) queryClient.invalidateQueries({ queryKey: ['todos'] }); // Matches ['todos', 1], ['todos', 2], etc.

// Predicate match queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'todos' && query.state.data?.status === 'draft' });

Best Practices:

  • Use arrays with hierarchical structure: ['resource', id, 'subresource']

  • Place variables at the end: ['users', { filter, sort }]

  • Consistent ordering across components

  • Use objects for complex parameters

Query Lifecycle

FRESH → STALE → INACTIVE → GARBAGE COLLECTED ↓ ↓ ↓ ↓ 0ms staleTime no observers cacheTime

States:

  • Fresh: Data is considered up-to-date (within staleTime )

  • Stale: Data might be outdated, will refetch on trigger

  • Inactive: No components using the query

  • Garbage Collected: Removed from cache after cacheTime

Configuration:

useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 5 * 60 * 1000, // 5 minutes (data fresh) gcTime: 10 * 60 * 1000, // 10 minutes (cache retention) refetchOnWindowFocus: true, // Refetch when window regains focus refetchOnReconnect: true, // Refetch when reconnecting refetchInterval: 30000, // Poll every 30 seconds });

Cache Behavior

Automatic Caching:

// First component - triggers fetch function ComponentA() { const { data } = useQuery({ queryKey: ['user', 1], queryFn: fetchUser }); return <div>{data?.name}</div>; }

// Second component - uses cache instantly function ComponentB() { const { data } = useQuery({ queryKey: ['user', 1], queryFn: fetchUser }); return <div>{data?.email}</div>; // No second fetch! }

Stale-While-Revalidate:

// Shows cached data immediately, refetches in background if stale const { data, isRefetching } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts, staleTime: 60000, // Fresh for 1 minute });

// data available from cache immediately // isRefetching = true if background refetch happening

Queries

useQuery Hook

Basic Syntax:

const { data, // Query result error, // Error object if failed isLoading, // First load (no cached data) isFetching, // Any fetch (including background) isSuccess, // Query succeeded isError, // Query failed status, // 'pending' | 'error' | 'success' fetchStatus, // 'fetching' | 'paused' | 'idle' refetch, // Manual refetch function } = useQuery({ queryKey: ['key'], queryFn: async () => { /* fetch logic */ }, });

Query Function Patterns

Basic Fetch:

const { data } = useQuery({ queryKey: ['users'], queryFn: async () => { const response = await fetch('/api/users'); if (!response.ok) throw new Error('Network error'); return response.json(); }, });

Query Key in Function:

const { data } = useQuery({ queryKey: ['user', userId], queryFn: async ({ queryKey }) => { const [_key, userId] = queryKey; const response = await fetch(/api/users/${userId}); return response.json(); }, });

Abort Signal (Cancellation):

const { data } = useQuery({ queryKey: ['todos'], queryFn: async ({ signal }) => { const response = await fetch('/api/todos', { signal }); return response.json(); }, }); // Automatically cancels on unmount or when query becomes inactive

Axios Pattern:

import axios from 'axios';

const { data } = useQuery({ queryKey: ['repos', username], queryFn: ({ signal }) => axios.get(/api/repos/${username}, { signal }).then(res => res.data), });

Dependent Queries

Sequential Queries:

// Wait for user before fetching projects const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });

const { data: projects } = useQuery({ queryKey: ['projects', user?.id], queryFn: () => fetchProjects(user!.id), enabled: !!user, // Only run when user exists });

Conditional Queries:

const { data } = useQuery({ queryKey: ['premium-features', userId], queryFn: fetchPremiumFeatures, enabled: user?.isPremium === true, // Only fetch for premium users });

Parallel Queries

Manual Parallel:

function Dashboard() { const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers }); const posts = useQuery({ queryKey: ['posts'], queryFn: fetchPosts }); const projects = useQuery({ queryKey: ['projects'], queryFn: fetchProjects });

if (users.isLoading || posts.isLoading || projects.isLoading) { return <Spinner />; }

return <div>/* render dashboard */</div>; }

useQueries (Dynamic Parallel):

import { useQueries } from '@tanstack/react-query';

function MultiUserProfiles({ userIds }: { userIds: number[] }) { const results = useQueries({ queries: userIds.map(id => ({ queryKey: ['user', id], queryFn: () => fetchUser(id), staleTime: 60000, })), });

const allLoaded = results.every(r => r.isSuccess);

if (!allLoaded) return <Spinner />;

return ( <div> {results.map((result, i) => ( <UserCard key={userIds[i]} user={result.data} /> ))} </div> ); }

Query Placeholders

Placeholder Data (Instant UI):

const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, placeholderData: [], // Show empty array while loading });

// Dynamic placeholder from cache const { data } = useQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id), placeholderData: () => { // Use cached list to find placeholder return queryClient .getQueryData(['todos']) ?.find(d => d.id === id); }, });

Initial Data (Hydration):

const { data } = useQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id), initialData: () => { return queryClient .getQueryData(['todos']) ?.find(d => d.id === id); }, initialDataUpdatedAt: () => queryClient.getQueryState(['todos'])?.dataUpdatedAt, });

Difference:

  • placeholderData : Not persisted to cache, purely UI

  • initialData : Persisted to cache as real data

Mutations

useMutation Hook

Basic Mutation:

const mutation = useMutation({ mutationFn: async (newTodo: Todo) => { const response = await fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo), }); return response.json(); }, onSuccess: (data) => { console.log('Created:', data); }, onError: (error) => { console.error('Failed:', error); }, });

// Trigger mutation mutation.mutate({ title: 'New Todo', done: false });

// Async/await variant try { const data = await mutation.mutateAsync(newTodo); console.log(data); } catch (error) { console.error(error); }

Mutation State:

const { mutate, // Trigger function mutateAsync, // Promise variant data, // Result from successful mutation error, // Error from failed mutation isPending, // Mutation in progress isSuccess, // Mutation succeeded isError, // Mutation failed reset, // Reset mutation state } = useMutation({ /* ... */ });

Cache Invalidation

Invalidate Queries After Mutation:

const mutation = useMutation({ mutationFn: createTodo, onSuccess: () => { // Refetch all 'todos' queries queryClient.invalidateQueries({ queryKey: ['todos'] }); }, });

Multiple Invalidations:

const mutation = useMutation({ mutationFn: updateUser, onSuccess: (data, variables) => { // Invalidate multiple query families queryClient.invalidateQueries({ queryKey: ['user', variables.id] }); queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['teams', data.teamId] }); }, });

Selective Invalidation:

// Only invalidate specific queries queryClient.invalidateQueries({ queryKey: ['todos'], exact: true, // Only ['todos'], not ['todos', 1] });

// Predicate-based invalidation queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'todos' && query.state.data?.status === 'draft', });

Manual Cache Updates

setQueryData (Direct Update):

const mutation = useMutation({ mutationFn: updateTodo, onSuccess: (updatedTodo) => { // Update specific todo in cache queryClient.setQueryData( ['todo', updatedTodo.id], updatedTodo );

// Update todo in list
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
  old.map(todo =>
    todo.id === updatedTodo.id ? updatedTodo : todo
  )
);

}, });

Immutable Updates:

// Add to list queryClient.setQueryData(['todos'], (old: Todo[] = []) => [...old, newTodo] );

// Remove from list queryClient.setQueryData(['todos'], (old: Todo[] = []) => old.filter(todo => todo.id !== deletedId) );

// Update in list queryClient.setQueryData(['todos'], (old: Todo[] = []) => old.map(todo => todo.id === id ? { ...todo, ...updates } : todo) );

Optimistic Updates

Basic Optimistic Update

const mutation = useMutation({ mutationFn: updateTodo, // Before mutation executes onMutate: async (newTodo) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['todos'] });

// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);

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

// Return context with snapshot
return { previousTodos };

}, // On error, rollback onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context?.previousTodos); }, // Always refetch after success or error onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); }, });

Complex Optimistic Update Pattern

interface Todo { id: number; title: string; done: boolean; }

const useUpdateTodo = () => { const queryClient = useQueryClient();

return useMutation({ mutationFn: async (updatedTodo: Todo) => { const response = await fetch(/api/todos/${updatedTodo.id}, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedTodo), }); if (!response.ok) throw new Error('Update failed'); return response.json(); },

onMutate: async (updatedTodo) => {
  // Cancel queries to prevent race conditions
  await queryClient.cancelQueries({ queryKey: ['todos'] });
  await queryClient.cancelQueries({ queryKey: ['todo', updatedTodo.id] });

  // Snapshot current state
  const previousTodos = queryClient.getQueryData&#x3C;Todo[]>(['todos']);
  const previousTodo = queryClient.getQueryData&#x3C;Todo>(['todo', updatedTodo.id]);

  // Optimistically update list
  if (previousTodos) {
    queryClient.setQueryData&#x3C;Todo[]>(['todos'], (old = []) =>
      old.map(todo => todo.id === updatedTodo.id ? updatedTodo : todo)
    );
  }

  // Optimistically update detail
  queryClient.setQueryData(['todo', updatedTodo.id], updatedTodo);

  return { previousTodos, previousTodo };
},

onError: (err, updatedTodo, context) => {
  // Rollback on error
  if (context?.previousTodos) {
    queryClient.setQueryData(['todos'], context.previousTodos);
  }
  if (context?.previousTodo) {
    queryClient.setQueryData(['todo', updatedTodo.id], context.previousTodo);
  }
},

onSettled: (data, error, variables) => {
  // Always refetch to ensure sync
  queryClient.invalidateQueries({ queryKey: ['todos'] });
  queryClient.invalidateQueries({ queryKey: ['todo', variables.id] });
},

}); };

// Usage function TodoItem({ todo }: { todo: Todo }) { const updateTodo = useUpdateTodo();

const toggleDone = () => { updateTodo.mutate({ ...todo, done: !todo.done }); };

return ( <div> <input type="checkbox" checked={todo.done} onChange={toggleDone} disabled={updateTodo.isPending} /> {todo.title} </div> ); }

Pagination

useInfiniteQuery (Infinite Scroll)

Basic Infinite Query:

import { useInfiniteQuery } from '@tanstack/react-query';

interface PostsResponse { posts: Post[]; nextCursor?: number; }

function InfinitePosts() { const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: async ({ pageParam = 0 }) => { const response = await fetch(/api/posts?cursor=${pageParam}); return response.json() as Promise<PostsResponse>; }, getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: 0, });

return ( <div> {data?.pages.map((page, i) => ( <div key={i}> {page.posts.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> ))}

  &#x3C;button
    onClick={() => fetchNextPage()}
    disabled={!hasNextPage || isFetchingNextPage}
  >
    {isFetchingNextPage
      ? 'Loading more...'
      : hasNextPage
      ? 'Load More'
      : 'Nothing more to load'}
  &#x3C;/button>
&#x3C;/div>

); }

Bi-directional Pagination:

const { data, fetchNextPage, fetchPreviousPage, hasNextPage, hasPreviousPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: async ({ pageParam = 0 }) => { const response = await fetch(/api/posts?cursor=${pageParam}); return response.json(); }, getNextPageParam: (lastPage) => lastPage.nextCursor, getPreviousPageParam: (firstPage) => firstPage.prevCursor, initialPageParam: 0, });

Infinite Scroll with Intersection Observer:

import { useInfiniteQuery } from '@tanstack/react-query'; import { useInView } from 'react-intersection-observer'; import { useEffect } from 'react';

function AutoLoadPosts() { const { ref, inView } = useInView();

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: 0, });

// Auto-fetch when sentinel comes into view useEffect(() => { if (inView && hasNextPage) { fetchNextPage(); } }, [inView, hasNextPage, fetchNextPage]);

return ( <div> {data?.pages.map((page, i) => ( <div key={i}> {page.posts.map(post => <PostCard key={post.id} post={post} />)} </div> ))}

  {/* Sentinel element */}
  &#x3C;div ref={ref}>
    {isFetchingNextPage &#x26;&#x26; &#x3C;Spinner />}
  &#x3C;/div>
&#x3C;/div>

); }

Traditional Pagination

Page-Based Pagination:

function PaginatedPosts() { const [page, setPage] = useState(1);

const { data, isLoading } = useQuery({ queryKey: ['posts', page], queryFn: () => fetchPosts(page), placeholderData: (previousData) => previousData, // Keep previous data while loading });

return ( <div> {isLoading ? ( <Spinner /> ) : ( <div> {data.posts.map(post => <PostCard key={post.id} post={post} />)} </div> )}

  &#x3C;div>
    &#x3C;button
      onClick={() => setPage(old => Math.max(old - 1, 1))}
      disabled={page === 1}
    >
      Previous
    &#x3C;/button>
    &#x3C;span>Page {page}&#x3C;/span>
    &#x3C;button
      onClick={() => setPage(old => old + 1)}
      disabled={!data?.hasMore}
    >
      Next
    &#x3C;/button>
  &#x3C;/div>
&#x3C;/div>

); }

Prefetch Next Page:

function PaginatedPosts() { const queryClient = useQueryClient(); const [page, setPage] = useState(1);

const { data } = useQuery({ queryKey: ['posts', page], queryFn: () => fetchPosts(page), });

// Prefetch next page useEffect(() => { if (data?.hasMore) { queryClient.prefetchQuery({ queryKey: ['posts', page + 1], queryFn: () => fetchPosts(page + 1), }); } }, [data, page, queryClient]);

return ( <div> {/* ... */} </div> ); }

Cache Management

Query Client Methods

getQueryData (Read Cache):

const todos = queryClient.getQueryData<Todo[]>(['todos']); const user = queryClient.getQueryData<User>(['user', userId]);

setQueryData (Write Cache):

queryClient.setQueryData(['user', 1], newUser);

// Updater function queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [...old, newTodo]);

invalidateQueries (Mark Stale + Refetch):

// Invalidate all queries queryClient.invalidateQueries();

// Invalidate by key prefix queryClient.invalidateQueries({ queryKey: ['todos'] });

// Exact match only queryClient.invalidateQueries({ queryKey: ['todos'], exact: true });

// With refetch control queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'active', // 'active' | 'inactive' | 'all' | 'none' });

refetchQueries (Immediate Refetch):

// Refetch all active queries await queryClient.refetchQueries();

// Refetch specific queries await queryClient.refetchQueries({ queryKey: ['todos'] });

// Refetch with filters await queryClient.refetchQueries({ queryKey: ['todos'], type: 'active', // Only refetch active queries });

removeQueries (Delete from Cache):

// Remove all queries queryClient.removeQueries();

// Remove specific queryClient.removeQueries({ queryKey: ['todos', 1] });

// Remove with predicate queryClient.removeQueries({ predicate: (query) => query.queryKey[0] === 'todos' && query.state.data?.isArchived === true, });

resetQueries (Reset to Initial State):

// Reset all queries queryClient.resetQueries();

// Reset specific queryClient.resetQueries({ queryKey: ['todos'] });

Cache Configuration

Global Defaults:

const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 minute gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime) refetchOnWindowFocus: false, refetchOnReconnect: true, retry: 3, retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), }, mutations: { retry: 1, }, }, });

Per-Query Configuration:

useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: Infinity, // Never mark stale gcTime: Infinity, // Never garbage collect refetchInterval: 5000, // Refetch every 5s refetchIntervalInBackground: false, // Don't refetch when tab inactive });

Cache Persistence

Persist to LocalStorage:

import { QueryClient } from '@tanstack/react-query'; import { persistQueryClient } from '@tanstack/react-query-persist-client'; import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 1000 * 60 * 60 * 24, // 24 hours }, }, });

const persister = createSyncStoragePersister({ storage: window.localStorage, });

persistQueryClient({ queryClient, persister, maxAge: 1000 * 60 * 60 * 24, // 24 hours });

IndexedDB Persistence:

import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; import { get, set, del } from 'idb-keyval';

const persister = createAsyncStoragePersister({ storage: { getItem: async (key) => await get(key), setItem: async (key, value) => await set(key, value), removeItem: async (key) => await del(key), }, });

Error Handling and Retry

Error Handling

Query Error Boundaries:

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

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

// Component throws errors to boundary function Component() { const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser, throwOnError: true, // Throw errors to error boundary }); return <div>{data.name}</div>; }

Custom Error Types:

class APIError extends Error { constructor( message: string, public status: number, public code?: string ) { super(message); this.name = 'APIError'; } }

const { error } = useQuery({ queryKey: ['user'], queryFn: async () => { const response = await fetch('/api/user'); if (!response.ok) { throw new APIError( 'Failed to fetch user', response.status, await response.text() ); } return response.json(); }, });

if (error instanceof APIError) { if (error.status === 404) return <NotFound />; if (error.status === 401) return <Unauthorized />; }

Retry Logic

Default Retry:

// Retries 3 times with exponential backoff useQuery({ queryKey: ['data'], queryFn: fetchData, retry: 3, // Number of retries retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), });

Conditional Retry:

useQuery({ queryKey: ['data'], queryFn: fetchData, retry: (failureCount, error) => { // Don't retry on 404 if (error instanceof APIError && error.status === 404) { return false; } // Retry up to 3 times for other errors return failureCount < 3; }, });

Mutation Retry:

useMutation({ mutationFn: createUser, retry: 2, // Retry mutations (use sparingly) retryDelay: 1000, });

Network Status Detection

Online/Offline Handling:

const queryClient = new QueryClient({ defaultOptions: { queries: { networkMode: 'offlineFirst', // 'online' | 'always' | 'offlineFirst' refetchOnReconnect: true, }, }, });

// Custom online/offline indicator function OnlineStatus() { const queryClient = useQueryClient(); const isOnline = useOnlineManager().isOnline();

useEffect(() => { if (isOnline) { queryClient.refetchQueries(); } }, [isOnline, queryClient]);

return isOnline ? <OnlineIcon /> : <OfflineIcon />; }

SSR and Hydration

Next.js App Router

Server Component Data Fetching:

// app/users/page.tsx import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; import { UsersList } from './UsersList';

export default async function UsersPage() { const queryClient = new QueryClient();

// Prefetch on server await queryClient.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsers, });

return ( <HydrationBoundary state={dehydrate(queryClient)}> <UsersList /> </HydrationBoundary> ); }

Client Component:

// app/users/UsersList.tsx 'use client';

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

export function UsersList() { // Uses hydrated data from server const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, });

return ( <ul> {data?.map(user => <li key={user.id}>{user.name}</li>)} </ul> ); }

Next.js Pages Router

getServerSideProps:

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

export async function getServerSideProps() { const queryClient = new QueryClient();

await queryClient.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsers, });

return { props: { dehydratedState: dehydrate(queryClient), }, }; }

function UsersPage() { const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, });

return <div>{/* ... */}</div>; }

export default UsersPage;

_app.tsx Setup:

// pages/_app.tsx import { useState } from 'react'; import { QueryClient, QueryClientProvider, HydrationBoundary } from '@tanstack/react-query';

export default function App({ Component, pageProps }) { const [queryClient] = useState(() => new QueryClient());

return ( <QueryClientProvider client={queryClient}> <HydrationBoundary state={pageProps.dehydratedState}> <Component {...pageProps} /> </HydrationBoundary> </QueryClientProvider> ); }

Streaming SSR

Suspense Integration:

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

function UserProfile({ userId }: { userId: number }) { const { data } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });

// No loading state needed - Suspense handles it return <div>{data.name}</div>; }

// In parent component <Suspense fallback={<Spinner />}> <UserProfile userId={1} /> </Suspense>

Integration Patterns

tRPC Integration

Setup:

// utils/trpc.ts import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

Provider:

// app/providers.tsx 'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { httpBatchLink } from '@trpc/client'; import { useState } from 'react'; import { trpc } from '@/utils/trpc';

export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ url: '/api/trpc', }), ], }) );

return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </trpc.Provider> ); }

Usage:

function UserProfile() { // Query const { data } = trpc.user.getById.useQuery({ id: 1 });

// Mutation const utils = trpc.useUtils(); const mutation = trpc.user.create.useMutation({ onSuccess: () => { utils.user.list.invalidate(); }, });

return <div>{data?.name}</div>; }

REST API with Axios

API Client:

// lib/api-client.ts import axios from 'axios';

export const apiClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, headers: { 'Content-Type': 'application/json', }, });

// Request interceptor apiClient.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = Bearer ${token}; } return config; });

// Response interceptor apiClient.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { // Handle unauthorized window.location.href = '/login'; } return Promise.reject(error); } );

Query Hooks:

// hooks/useUsers.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client';

export function useUsers() { return useQuery({ queryKey: ['users'], queryFn: async ({ signal }) => { const { data } = await apiClient.get('/users', { signal }); return data; }, }); }

export function useUser(id: number) { return useQuery({ queryKey: ['user', id], queryFn: async ({ signal }) => { const { data } = await apiClient.get(/users/${id}, { signal }); return data; }, enabled: !!id, }); }

export function useCreateUser() { const queryClient = useQueryClient();

return useMutation({ mutationFn: (newUser: NewUser) => apiClient.post('/users', newUser).then(res => res.data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); }, }); }

GraphQL Integration

Apollo Client Alternative:

import { useQuery } from '@tanstack/react-query'; import { request, gql } from 'graphql-request';

const endpoint = 'https://api.example.com/graphql';

const GET_USERS = gql query GetUsers { users { id name email } };

function useUsers() { return useQuery({ queryKey: ['users'], queryFn: async () => request(endpoint, GET_USERS), }); }

// With variables const GET_USER = gql query GetUser($id: ID!) { user(id: $id) { id name email } };

function useUser(id: string) { return useQuery({ queryKey: ['user', id], queryFn: async () => request(endpoint, GET_USER, { id }), }); }

Zustand for Global State

Combined Pattern:

// store/useAuthStore.ts import { create } from 'zustand';

interface AuthState { token: string | null; setToken: (token: string | null) => void; logout: () => void; }

export const useAuthStore = create<AuthState>((set) => ({ token: localStorage.getItem('token'), setToken: (token) => { if (token) { localStorage.setItem('token', token); } else { localStorage.removeItem('token'); } set({ token }); }, logout: () => { localStorage.removeItem('token'); set({ token: null }); }, }));

// hooks/useAuthenticatedQuery.ts import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '@/store/useAuthStore';

export function useAuthenticatedQuery() { const token = useAuthStore(state => state.token);

return useQuery({ queryKey: ['profile', token], queryFn: async () => { const response = await fetch('/api/profile', { headers: { Authorization: Bearer ${token} }, }); return response.json(); }, enabled: !!token, }); }

TypeScript Patterns

Typed Queries

Generic Query Hook:

interface User { id: number; name: string; email: string; }

// Explicit typing const { data } = useQuery<User, Error>({ queryKey: ['user', id], queryFn: async () => { const response = await fetch(/api/users/${id}); return response.json(); // TypeScript infers return type }, });

// data is User | undefined // error is Error | null

Type-safe Query Keys:

// Define query keys with types const userKeys = { all: ['users'] as const, lists: () => [...userKeys.all, 'list'] as const, list: (filters: UserFilters) => [...userKeys.lists(), filters] as const, details: () => [...userKeys.all, 'detail'] as const, detail: (id: number) => [...userKeys.details(), id] as const, };

// Usage with full type safety const { data } = useQuery({ queryKey: userKeys.detail(userId), queryFn: () => fetchUser(userId), });

// Invalidate with autocomplete queryClient.invalidateQueries({ queryKey: userKeys.lists() });

Custom Hook with Types:

interface User { id: number; name: string; email: string; }

interface UseUserOptions { enabled?: boolean; onSuccess?: (user: User) => void; }

function useUser(id: number, options?: UseUserOptions) { return useQuery({ queryKey: ['user', id], queryFn: async (): Promise<User> => { const response = await fetch(/api/users/${id}); if (!response.ok) throw new Error('Failed to fetch user'); return response.json(); }, enabled: options?.enabled, // Type-safe callbacks onSuccess: options?.onSuccess, }); }

// Usage const { data } = useUser(1, { enabled: true, onSuccess: (user) => { console.log(user.name); // TypeScript knows user is User }, });

Typed Mutations

interface CreateUserPayload { name: string; email: string; }

interface User { id: number; name: string; email: string; }

function useCreateUser() { return useMutation<User, Error, CreateUserPayload>({ mutationFn: async (payload) => { const response = await fetch('/api/users', { method: 'POST', body: JSON.stringify(payload), }); return response.json(); }, onSuccess: (data) => { // data is User console.log('Created user:', data.name); }, onError: (error) => { // error is Error console.error('Failed:', error.message); }, }); }

// Usage const mutation = useCreateUser(); mutation.mutate({ name: 'John', email: 'john@example.com' });

Query Client Typing

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

// Type-safe query client methods const user = queryClient.getQueryData<User>(['user', 1]);

queryClient.setQueryData<User>(['user', 1], (old) => { // old is User | undefined if (!old) return old; return { ...old, name: 'Updated' }; });

// Type-safe invalidation queryClient.invalidateQueries<User>({ queryKey: ['users'], predicate: (query) => { // query.state.data is User | undefined return query.state.data?.isActive === true; }, });

Testing

Setup Testing Environment

Test Utils:

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

export function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, // Don't retry failed queries in tests gcTime: Infinity, }, }, logger: { log: console.log, warn: console.warn, error: () => {}, // Silence errors in tests }, }); }

export function renderWithClient(ui: ReactNode) { const testQueryClient = createTestQueryClient();

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

Testing Queries

Basic Query Test:

// UserProfile.test.tsx import { renderWithClient } from '@/test/utils'; import { screen, waitFor } from '@testing-library/react'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { UserProfile } from './UserProfile';

const server = setupServer( rest.get('/api/users/1', (req, res, ctx) => { return res( ctx.json({ id: 1, name: 'John Doe', email: 'john@example.com', }) ); }) );

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

test('displays user profile', async () => { renderWithClient(<UserProfile userId={1} />);

expect(screen.getByText('Loading...')).toBeInTheDocument();

await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument(); }); });

test('handles fetch error', async () => { server.use( rest.get('/api/users/1', (req, res, ctx) => { return res(ctx.status(500)); }) );

renderWithClient(<UserProfile userId={1} />);

await waitFor(() => { expect(screen.getByText(/error/i)).toBeInTheDocument(); }); });

Testing Mutations

// CreateUserForm.test.tsx import { renderWithClient } from '@/test/utils'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { CreateUserForm } from './CreateUserForm';

const server = setupServer( rest.post('/api/users', async (req, res, ctx) => { const body = await req.json(); return res( ctx.json({ id: 1, ...body, }) ); }) );

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

test('creates user successfully', async () => { const user = userEvent.setup(); renderWithClient(<CreateUserForm />);

await user.type(screen.getByPlaceholderText('Name'), 'John Doe'); await user.type(screen.getByPlaceholderText('Email'), 'john@example.com'); await user.click(screen.getByRole('button', { name: /create/i }));

await waitFor(() => { expect(screen.getByText(/created successfully/i)).toBeInTheDocument(); }); });

Testing with Mock Data

Hydrate Query Data:

test('renders with initial data', () => { const testQueryClient = createTestQueryClient();

// Pre-populate cache testQueryClient.setQueryData(['user', 1], { id: 1, name: 'John Doe', email: 'john@example.com', });

render( <QueryClientProvider client={testQueryClient}> <UserProfile userId={1} /> </QueryClientProvider> );

// Data immediately available (no loading state) expect(screen.getByText('John Doe')).toBeInTheDocument(); });

Testing Custom Hooks

// useUser.test.ts import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { useUser } from './useUser';

const server = setupServer( rest.get('/api/users/:id', (req, res, ctx) => { return res(ctx.json({ id: 1, name: 'John Doe' })); }) );

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

test('fetches user data', async () => { const queryClient = new QueryClient(); const wrapper = ({ children }: { children: React.ReactNode }) => ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> );

const { result } = renderHook(() => useUser(1), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data).toEqual({ id: 1, name: 'John Doe' }); });

Performance Optimization

Query Deduplication

Automatic Deduplication:

// Multiple components request same data - only one network request function Dashboard() { return ( <div> <UserStats userId={1} /> {/* Triggers fetch /} <UserProfile userId={1} /> {/ Uses cache /} <UserActivity userId={1} /> {/ Uses cache */} </div> ); }

Prefetching

Hover Prefetch:

function UserLink({ userId }: { userId: number }) { const queryClient = useQueryClient();

const prefetchUser = () => { queryClient.prefetchQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), staleTime: 60000, }); };

return ( <Link href={/users/${userId}} onMouseEnter={prefetchUser} onFocus={prefetchUser} > View User </Link> ); }

Route Prefetch:

// Next.js App Router import { QueryClient, HydrationBoundary, dehydrate } from '@tanstack/react-query';

export default async function UserPage({ params }: { params: { id: string } }) { const queryClient = new QueryClient();

// Prefetch user data await queryClient.prefetchQuery({ queryKey: ['user', params.id], queryFn: () => fetchUser(params.id), });

// Prefetch related data await queryClient.prefetchQuery({ queryKey: ['user-posts', params.id], queryFn: () => fetchUserPosts(params.id), });

return ( <HydrationBoundary state={dehydrate(queryClient)}> <UserProfile userId={params.id} /> </HydrationBoundary> ); }

Select and Transform Data

Memo-ized Selectors:

// Only re-render when selected data changes function TodoList({ filter }: { filter: 'all' | 'done' | 'pending' }) { const { data: filteredTodos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select: (todos) => { // This only runs when todos change if (filter === 'done') return todos.filter(t => t.done); if (filter === 'pending') return todos.filter(t => !t.done); return todos; }, });

// Component only re-renders when filteredTodos change return ( <ul> {filteredTodos?.map(todo => <li key={todo.id}>{todo.title}</li>)} </ul> ); }

Expensive Computations:

const { data: sortedUsers } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, select: (users) => { // Heavy sorting only runs when users change return users .slice() .sort((a, b) => a.name.localeCompare(b.name)); }, });

Structural Sharing

Automatic Structural Sharing:

// TanStack Query automatically does structural sharing const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, structuralSharing: true, // Default });

// If refetch returns identical data structure, // component doesn't re-render even though fetch completed

Custom Structural Sharing:

import { replaceEqualDeep } from '@tanstack/react-query';

const { data } = useQuery({ queryKey: ['data'], queryFn: fetchData, structuralSharing: (oldData, newData) => { // Custom comparison logic return replaceEqualDeep(oldData, newData); }, });

Query Cancellation

Abort In-Flight Requests:

const { data, refetch } = useQuery({ queryKey: ['search', searchTerm], queryFn: async ({ signal }) => { const response = await fetch(/api/search?q=${searchTerm}, { signal, // Pass abort signal }); return response.json(); }, });

// When searchTerm changes, previous request is cancelled automatically

Manual Cancellation:

const queryClient = useQueryClient();

// Cancel all queries queryClient.cancelQueries();

// Cancel specific query queryClient.cancelQueries({ queryKey: ['todos'] });

Best Practices and Common Patterns

Query Key Factories

Centralized Query Keys:

// lib/query-keys.ts export const queryKeys = { users: { all: ['users'] as const, lists: () => [...queryKeys.users.all, 'list'] as const, list: (filters: UserFilters) => [...queryKeys.users.lists(), filters] as const, details: () => [...queryKeys.users.all, 'detail'] as const, detail: (id: number) => [...queryKeys.users.details(), id] as const, }, posts: { all: ['posts'] as const, lists: () => [...queryKeys.posts.all, 'list'] as const, list: (filters: PostFilters) => [...queryKeys.posts.lists(), filters] as const, details: () => [...queryKeys.posts.all, 'detail'] as const, detail: (id: number) => [...queryKeys.posts.details(), id] as const, }, };

// Usage const { data } = useQuery({ queryKey: queryKeys.users.detail(userId), queryFn: () => fetchUser(userId), });

// Invalidate all user lists queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });

Custom Hook Patterns

Resource Hook Factory:

// lib/create-resource-hooks.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

export function createResourceHooks<T, CreateT = Partial<T>, UpdateT = Partial<T>>( resourceName: string, api: { getAll: () => Promise<T[]>; getOne: (id: string | number) => Promise<T>; create: (data: CreateT) => Promise<T>; update: (id: string | number, data: UpdateT) => Promise<T>; delete: (id: string | number) => Promise<void>; } ) { const keys = { all: [resourceName] as const, lists: () => [...keys.all, 'list'] as const, details: () => [...keys.all, 'detail'] as const, detail: (id: string | number) => [...keys.details(), id] as const, };

return { useList: () => useQuery({ queryKey: keys.lists(), queryFn: api.getAll, }),

useDetail: (id: string | number) =>
  useQuery({
    queryKey: keys.detail(id),
    queryFn: () => api.getOne(id),
    enabled: !!id,
  }),

useCreate: () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: api.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: keys.lists() });
    },
  });
},

useUpdate: () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ id, data }: { id: string | number; data: UpdateT }) =>
      api.update(id, data),
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({ queryKey: keys.detail(id) });
      queryClient.invalidateQueries({ queryKey: keys.lists() });
    },
  });
},

useDelete: () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: api.delete,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: keys.lists() });
    },
  });
},

}; }

// Usage const userHooks = createResourceHooks('users', userApi);

function UsersList() { const { data: users } = userHooks.useList(); const createUser = userHooks.useCreate(); const deleteUser = userHooks.useDelete();

return ( <div> {users?.map(user => ( <div key={user.id}> {user.name} <button onClick={() => deleteUser.mutate(user.id)}>Delete</button> </div> ))} </div> ); }

Error Handling Patterns

Centralized Error Handler:

// lib/query-client.ts import { QueryClient } from '@tanstack/react-query'; import { toast } from 'sonner';

export const queryClient = new QueryClient({ defaultOptions: { queries: { onError: (error) => { if (error instanceof APIError) { toast.error(Error: ${error.message}); } }, }, mutations: { onError: (error) => { toast.error(Failed to save: ${error.message}); }, }, }, });

Migration from SWR

SWR to TanStack Query:

// Before (SWR) import useSWR from 'swr';

function Profile() { const { data, error, mutate } = useSWR('/api/user', fetcher);

if (error) return <div>Error</div>; if (!data) return <div>Loading...</div>; return <div>{data.name}</div>; }

// After (TanStack Query) import { useQuery, useQueryClient } from '@tanstack/react-query';

function Profile() { const { data, error, isLoading } = useQuery({ queryKey: ['/api/user'], queryFn: () => fetcher('/api/user'), });

const queryClient = useQueryClient(); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['/api/user'] });

if (error) return <div>Error</div>; if (isLoading) return <div>Loading...</div>; return <div>{data.name}</div>; }

Comparison:

  • useSWR(key, fetcher) → useQuery({ queryKey: [key], queryFn: fetcher })

  • mutate() → queryClient.invalidateQueries()

  • !data loading → isLoading

  • useSWRConfig() → useQueryClient()

DevTools

Setup DevTools:

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

function App() { return ( <QueryClientProvider client={queryClient}> <YourApp /> <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> ); }

Production Build:

// DevTools are automatically excluded in production builds // No need to conditionally render

DevTools Features:

  • View all queries and their states

  • Inspect query data and errors

  • Manually trigger refetch

  • Invalidate queries

  • View query timelines

  • Monitor cache size

  • Debug network waterfalls

Common Pitfalls

❌ Don't Create QueryClient Inside Component:

// WRONG - Creates new client on every render function App() { const queryClient = new QueryClient(); // ❌ return <QueryClientProvider client={queryClient}>...</QueryClientProvider>; }

// CORRECT - Stable client instance function App() { const [queryClient] = useState(() => new QueryClient()); // ✅ return <QueryClientProvider client={queryClient}>...</QueryClientProvider>; }

❌ Don't Use Query Data in Render Without Checking:

// WRONG - data might be undefined function UserProfile() { const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser }); return <div>{data.name}</div>; // ❌ Crashes if data is undefined }

// CORRECT - Handle loading state function UserProfile() { const { data, isLoading } = useQuery({ queryKey: ['user'], queryFn: fetchUser }); if (isLoading) return <Spinner />; // ✅ return <div>{data.name}</div>; }

❌ Don't Forget Query Keys Are Dependencies:

// WRONG - Missing dependency in query key function UserPosts({ userId, filter }: Props) { const { data } = useQuery({ queryKey: ['posts'], // ❌ Missing userId and filter queryFn: () => fetchUserPosts(userId, filter), }); }

// CORRECT - All dependencies in key function UserPosts({ userId, filter }: Props) { const { data } = useQuery({ queryKey: ['posts', userId, filter], // ✅ queryFn: () => fetchUserPosts(userId, filter), }); }

❌ Don't Mutate Query Data Directly:

// WRONG - Mutating cached data const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }); data.push(newTodo); // ❌ Mutates cache directly

// CORRECT - Use setQueryData queryClient.setQueryData(['todos'], (old = []) => [...old, newTodo]); // ✅

Summary

TanStack Query is the industry-standard solution for server-state management in React applications. Use it for API data fetching, caching, synchronization, and real-time updates. It eliminates manual state management boilerplate and provides powerful features like automatic background refetching, optimistic updates, pagination, and intelligent cache management.

Key Takeaways:

  • Use useQuery for fetching data with automatic caching

  • Use useMutation for create/update/delete operations

  • Query keys are the foundation of cache management

  • Invalidate queries after mutations to keep UI in sync

  • Leverage optimistic updates for instant UI feedback

  • Use useInfiniteQuery for pagination and infinite scroll

  • Combine with Zustand for client-state management

  • Integrate seamlessly with tRPC, REST, and GraphQL

  • Type everything with TypeScript for full type safety

  • Test with MSW for realistic API mocking

Progressive Loading Pattern:

  • Entry Point: Quick start and basic setup

  • Intermediate: Queries, mutations, and cache management

  • Advanced: Optimistic updates, SSR, integrations, and performance

For additional resources, visit the official documentation.

Related Skills

When using Tanstack Query, these skills enhance your workflow:

  • react: React hooks and patterns for integrating TanStack Query

  • nextjs: TanStack Query with Next.js App Router and Server Components

  • zustand: Complementary client-state management (use together for hybrid state)

  • test-driven-development: Testing queries, mutations, and cache behavior

[Full documentation available in these skills if deployed in your bundle]

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

drizzle-orm

No summary provided by upstream source.

Repository SourceNeeds Review
General

pydantic

No summary provided by upstream source.

Repository SourceNeeds Review
General

playwright-e2e-testing

No summary provided by upstream source.

Repository SourceNeeds Review
General

tailwind-css

No summary provided by upstream source.

Repository SourceNeeds Review