Smithery Logo
MCPsSkillsDocsPricing
Login
Smithery Logo

Accelerating the Agent Economy

Resources

DocumentationPrivacy PolicySystem Status

Company

PricingAboutBlog

Connect

© 2026 Smithery. All rights reserved.

    bobmatnyc

    tanstack-query

    bobmatnyc/tanstack-query
    Coding
    10
    2 installs

    About

    SKILL.md

    Install

    Install via Skills CLI

    or add to your agent
    • Claude Code
      Claude Code
    • Codex
      Codex
    • OpenClaw
      OpenClaw
    • Cursor
      Cursor
    • Amp
      Amp
    • GitHub Copilot
      GitHub Copilot
    • Gemini CLI
      Gemini CLI
    • Kilo Code
      Kilo Code
    • Junie
      Junie
    • Replit
      Replit
    • Windsurf
      Windsurf
    • Cline
      Cline
    • Continue
      Continue
    • OpenCode
      OpenCode
    • OpenHands
      OpenHands
    • Roo Code
      Roo Code
    • Augment
      Augment
    • Goose
      Goose
    • Trae
      Trae
    • Zencoder
      Zencoder
    • Antigravity
      Antigravity
    ├─
    ├─
    └─

    About

    TanStack Query (React Query) for asynchronous server-state management with automatic caching, background refetching, optimistic updates, and pagination in React applications.

    SKILL.md

    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<Todo[]>(['todos']);
          const previousTodo = queryClient.getQueryData<Todo>(['todo', updatedTodo.id]);
    
          // Optimistically update list
          if (previousTodos) {
            queryClient.setQueryData<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>
          ))}
    
          <button
            onClick={() => fetchNextPage()}
            disabled={!hasNextPage || isFetchingNextPage}
          >
            {isFetchingNextPage
              ? 'Loading more...'
              : hasNextPage
              ? 'Load More'
              : 'Nothing more to load'}
          </button>
        </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 */}
          <div ref={ref}>
            {isFetchingNextPage && <Spinner />}
          </div>
        </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>
          )}
    
          <div>
            <button
              onClick={() => setPage(old => Math.max(old - 1, 1))}
              disabled={page === 1}
            >
              Previous
            </button>
            <span>Page {page}</span>
            <button
              onClick={() => setPage(old => old + 1)}
              disabled={!data?.hasMore}
            >
              Next
            </button>
          </div>
        </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]

    Repository
    bobmatnyc/claude-mpm-skills
    Files