From Beginner to Pro: Mastering State Management with TanStack Query v5


The complete guide to building production-ready React apps with TanStack Query v5, queryOptions, and best practices

Stack



📋 Table of Contents




🚀 Why TanStack Query v5?



The Problem It Solves

Traditional state management (Redux, Context API) treats server data like client state. This leads to:

  • Manual cache management – Writing cache update logic
  • Loading state hell – Managing loading/error states everywhere
  • Stale data – No automatic background refetching
  • Boilerplate overload – Hundreds of lines for simple data fetching



TanStack Query’s Solution

// Without TanStack Query: ~50 lines of code
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => 
  setLoading(true);
  fetch('/api/users')
    .then(res => res.json())
    .then(data => setUsers(data))
    .catch(err => setError(err))
    .finally(() => setLoading(false));
, []);

// With TanStack Query: ~5 lines
const  data: users, isLoading, error  = useQuery(
  queryKey: ['users'],
  queryFn: () => fetch('/api/users').then(res => res.json())
);
Enter fullscreen mode

Exit fullscreen mode



What’s New in v5?

  • 20% smaller bundle size than v4
  • queryOptions helper – Type-safe query definitions
  • Suspense is stableuseSuspenseQuery hook
  • Simplified optimistic updates – No manual cache writing
  • Better TypeScript support – Improved type inference
  • New isPending state – Clearer loading states
  • useMutationState – Access all mutation states globally
  • Infinite query improvementsmaxPages option



✅ Prerequisites

Before diving in, ensure you have:

  • Node.js (v18+)
  • React (v18+) – TanStack Query v5 requires React 18
  • TypeScript (v4.7+) – Recommended but optional
  • Basic understanding of:
    • React Hooks (useState, useEffect)
    • Async/Await and Promises
    • REST APIs



Quick Environment Check

node --version   # Should be v18+
npm --version    # Should be 8+
Enter fullscreen mode

Exit fullscreen mode




📁 Project Structure

tanstack-query-app/
├── src/
│   ├── api/                      # API layer
│   │   ├── axios-instance.ts     # Axios/Fetch setup
│   │   ├── endpoints.ts          # API endpoint definitions
│   │   └── types.ts              # API response types
│   ├── queries/                  # Query definitions (THE PATTERN)
│   │   ├── users.queries.ts      # User-related queries
│   │   ├── posts.queries.ts      # Post-related queries
│   │   └── index.ts              # Export all queries
│   ├── hooks/                    # Custom hooks
│   │   ├── useUsers.ts           # User data hooks
│   │   ├── usePosts.ts           # Post data hooks
│   │   └── useAuth.ts            # Auth hooks
│   ├── components/               # React components
│   │   ├── users/
│   │   │   ├── UserList.tsx
│   │   │   ├── UserDetail.tsx
│   │   │   └── CreateUser.tsx
│   │   └── posts/
│   │       ├── PostList.tsx
│   │       └── CreatePost.tsx
│   ├── lib/                      # Utilities
│   │   ├── query-client.ts       # QueryClient configuration
│   │   └── utils.ts              # Helper functions
│   ├── types/                    # TypeScript types
│   │   ├── user.types.ts
│   │   └── post.types.ts
│   ├── App.tsx                   # Root component
│   └── main.tsx                  # Entry point
├── .env                          # Environment variables
├── package.json
├── tsconfig.json
└── vite.config.ts
Enter fullscreen mode

Exit fullscreen mode




⚡ Quick Start



1. Create React Project

# Using Vite (recommended)
npm create vite@latest tanstack-query-app -- --template react-ts
cd tanstack-query-app

# Or using Create React App
npx create-react-app tanstack-query-app --template typescript
cd tanstack-query-app
Enter fullscreen mode

Exit fullscreen mode



2. Install Dependencies

# TanStack Query
npm install @tanstack/react-query

# DevTools (optional but highly recommended)
npm install @tanstack/react-query-devtools

# Axios for API calls
npm install axios

# Additional utilities
npm install react-router-dom
Enter fullscreen mode

Exit fullscreen mode



3. Setup QueryClient

Create src/lib/query-client.ts:

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

export const queryClient = new QueryClient(
  defaultOptions: 
    queries: 
      staleTime: 1000 * 60 * 5, // 5 minutes
      gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
      retry: 3,
      refetchOnWindowFocus: false,
      refetchOnReconnect: true,
    ,
    mutations: 
      retry: 1,
    ,
  ,
);
Enter fullscreen mode

Exit fullscreen mode



4. Wrap App with Provider

Update src/main.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import  QueryClientProvider  from '@tanstack/react-query';
import  ReactQueryDevtools  from '@tanstack/react-query-devtools';
import  queryClient  from './lib/query-client';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client=queryClient>
      <App />
      <ReactQueryDevtools initialIsOpen=false />
    </QueryClientProvider>
  </React.StrictMode>
);
Enter fullscreen mode

Exit fullscreen mode



5. Create Your First Query

Create src/queries/users.queries.ts:

import  queryOptions  from '@tanstack/react-query';
import axios from 'axios';

export interface User 
  id: number;
  name: string;
  email: string;
  username: string;


// API functions
const fetchUsers = async (): Promise<User[]> => 
  const  data  = await axios.get('https://jsonplaceholder.typicode.com/users');
  return data;
;

const fetchUserById = async (id: number): Promise<User> => 
  const  data  = await axios.get(`https://jsonplaceholder.typicode.com/users/$id`);
  return data;
;

// Query options using the queryOptions helper
export const usersQueryOptions = () =>
  queryOptions(
    queryKey: ['users'],
    queryFn: fetchUsers,
    staleTime: 1000 * 60 * 5, // 5 minutes
  );

export const userQueryOptions = (id: number) =>
  queryOptions(
    queryKey: ['users', id],
    queryFn: () => fetchUserById(id),
    staleTime: 1000 * 60 * 5,
  );
Enter fullscreen mode

Exit fullscreen mode



6. Use in Component

Create src/components/users/UserList.tsx:

import  useQuery  from '@tanstack/react-query';
import  usersQueryOptions  from '../../queries/users.queries';

export function UserList() 
  const  data: users, isLoading, error  = useQuery(usersQueryOptions());

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

  return (
    <div>
      <h2>Users</h2>
      <ul>
        users?.map(user => (
          <li key=user.id>
            user.name - user.email
          </li>
        ))
      </ul>
    </div>
  );

Enter fullscreen mode

Exit fullscreen mode



7. Run the App

npm run dev
Enter fullscreen mode

Exit fullscreen mode

Visit http://localhost:5173 and see TanStack Query in action! 🎉




🧠 Core Concepts



Query Keys

Query keys uniquely identify queries. They’re arrays that can contain:

// Simple key
['users']

// With parameters
['users', userId]

// With filters
['users',  status: 'active', role: 'admin' ]

// Hierarchical
['projects', projectId, 'tasks',  completed: false ]
Enter fullscreen mode

Exit fullscreen mode

Rules:

  • Keys are deterministic (same key = same query)
  • Order matters: ['users', 1]['users', '1']
  • Objects are compared deeply
  • Keys should be serializable



Query States

TanStack Query v5 has clear states:


  isPending: boolean,    // No data yet (replaces isLoading in v4)
  isLoading: boolean,    // isPending && isFetching (first load only)
  isError: boolean,      // Query failed
  isSuccess: boolean,    // Query succeeded
  isFetching: boolean,   // Currently fetching (background or initial)
  isRefetching: boolean, // Background refetch
  isStale: boolean,      // Data is stale (needs refetch)

Enter fullscreen mode

Exit fullscreen mode

Key difference in v5:

  • isPending – No cached data exists yet
  • isLoading – First-time fetch (isPending + isFetching)
  • isFetching – Any fetch (initial or background)



Stale Time vs GC Time


  staleTime: 1000 * 60 * 5,  // 5 minutes - how long data is "fresh"
  gcTime: 1000 * 60 * 10,    // 10 minutes - how long unused data stays in cache

Enter fullscreen mode

Exit fullscreen mode

Mental model:

  1. Fetch → Data is fresh (staleTime countdown starts)
  2. After staleTime → Data becomes stale (still in cache, shows immediately, but refetches in background)
  3. After gcTime of no usage → Data is garbage collected (removed from cache)
Fetch → [Fresh Period] → [Stale Period] → [GC Period] → Deleted
        ↑staleTime         ↑gcTime
Enter fullscreen mode

Exit fullscreen mode




🎯 queryOptions Pattern



Why queryOptions?

The queryOptions helper is THE recommended pattern in v5. It provides:

Type safety – Full TypeScript inference
Reusability – Share queries across components
Co-location – Keys and functions together
Prefetching – Use with prefetchQuery
Type-safe cache accessgetQueryData knows the type



Basic Pattern

import  queryOptions  from '@tanstack/react-query';

// Define query options
export const todoQueryOptions = (id: number) =>
  queryOptions(
    queryKey: ['todos', id],
    queryFn: async () => 
      const res = await fetch(`/api/todos/$id`);
      if (!res.ok) throw new Error('Failed to fetch todo');
      return res.json() as Promise<Todo>;
    ,
    staleTime: 1000 * 60,
  );

// Use in component
function TodoDetail( id :  id: number ) 
  const  data  = useQuery(todoQueryOptions(id));
  return <div>data?.title</div>;


// Use in prefetch
queryClient.prefetchQuery(todoQueryOptions(5));

// Type-safe cache access
const todo = queryClient.getQueryData(todoQueryOptions(5).queryKey);
//    ^? Todo | undefined (TypeScript knows the type!)
Enter fullscreen mode

Exit fullscreen mode



Complete Example: Posts API

Create src/api/axios-instance.ts:

import axios from 'axios';

export const api = axios.create( 'https://jsonplaceholder.typicode.com',
  headers: 
    'Content-Type': 'application/json',
  ,
);

// Request interceptor (add auth tokens, etc.)
api.interceptors.request.use(
  config => 
    const token = localStorage.getItem('token');
    if (token) 
      config.headers.Authorization = `Bearer $token`;
    
    return config;
  ,
  error => Promise.reject(error)
);

// Response interceptor (handle errors globally)
api.interceptors.response.use(
  response => response,
  error => 
    if (error.response?.status === 401) 
      // Handle unauthorized
      localStorage.removeItem('token');
      window.location.href = '/login';
    
    return Promise.reject(error);
  
);
Enter fullscreen mode

Exit fullscreen mode

Create src/types/post.types.ts:

export interface Post 
  id: number;
  userId: number;
  title: string;
  body: string;


export interface CreatePostDto 
  userId: number;
  title: string;
  body: string;


export interface UpdatePostDto 
  title?: string;
  body?: string;


export interface PostFilters 
  userId?: number;
  search?: string;

Enter fullscreen mode

Exit fullscreen mode

Create src/queries/posts.queries.ts:

import  queryOptions, infiniteQueryOptions  from '@tanstack/react-query';
import  api  from '../api/axios-instance';
import type  Post, PostFilters  from '../types/post.types';

// ============================================
// API Functions
// ============================================

const fetchPosts = async (filters?: PostFilters): Promise<Post[]> => 
  const  data  = await api.get<Post[]>('/posts',  params: filters );
  return data;
;

const fetchPostById = async (id: number): Promise<Post> => 
  const  data  = await api.get<Post>(`/posts/$id`);
  return data;
;

const fetchPostsByUser = async (userId: number): Promise<Post[]> => 
  const  data  = await api.get<Post[]>(`/posts?userId=$userId`);
  return data;
;

// ============================================
// Query Options (THE PATTERN)
// ============================================

/**
 * Fetch all posts with optional filters
 */
export const postsQueryOptions = (filters?: PostFilters) =>
  queryOptions(
    queryKey: ['posts', filters] as const,
    queryFn: () => fetchPosts(filters),
    staleTime: 1000 * 60 * 5, // 5 minutes
  );

/**
 * Fetch a single post by ID
 */
export const postQueryOptions = (id: number) =>
  queryOptions(
    queryKey: ['posts', id] as const,
    queryFn: () => fetchPostById(id),
    staleTime: 1000 * 60 * 5,
    // Only fetch if id is valid
    enabled: id > 0,
  );

/**
 * Fetch posts by user ID
 */
export const userPostsQueryOptions = (userId: number) =>
  queryOptions(
    queryKey: ['posts', 'user', userId] as const,
    queryFn: () => fetchPostsByUser(userId),
    staleTime: 1000 * 60 * 3,
  );

/**
 * Infinite query for paginated posts
 */
export const postsInfiniteQueryOptions = () =>
  infiniteQueryOptions(
    queryKey: ['posts', 'infinite'] as const,
    queryFn: async ( pageParam ) => 
      const  data  = await api.get<Post[]>('/posts', 
        params:  _page: pageParam, _limit: 10 ,
      );
      return data;
    ,
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages) => 
      return lastPage.length === 10 ? allPages.length + 1 : undefined;
    ,
    staleTime: 1000 * 60 * 5,
    // NEW in v5: Limit pages in cache
    maxPages: 3,
  );
Enter fullscreen mode

Exit fullscreen mode



Using Query Options in Components

import  useQuery, useSuspenseQuery  from '@tanstack/react-query';
import  postsQueryOptions, postQueryOptions  from '../queries/posts.queries';

// Regular query
function PostList() 
  const  data: posts, isLoading  = useQuery(postsQueryOptions());

  if (isLoading) return <div>Loading...</div>;
  return <div>posts?.map(post => <PostCard key=post.id post=post />)</div>;


// With filters
function FilteredPostList( userId :  userId: number ) 
  const  data: posts  = useQuery(postsQueryOptions( userId ));
  return <div>posts?.map(post => <PostCard key=post.id post=post />)</div>;


// Suspense query (v5 stable feature)
function PostDetail( id :  id: number ) 
  const  data: post  = useSuspenseQuery(postQueryOptions(id));
  // data is ALWAYS defined (never undefined) with useSuspenseQuery
  return (
    <div>
      <h1>post.title</h1>
      <p>post.body</p>
    </div>
  );


// Wrap in Suspense boundary
function PostDetailPage( id :  id: number ) 
  return (
    <Suspense fallback=<PostSkeleton />>
      <PostDetail id=id />
    </Suspense>
  );

Enter fullscreen mode

Exit fullscreen mode




🔍 Queries Deep Dive



Dependent Queries

Queries that depend on data from other queries:

function UserPosts( email :  email: string ) 
  // First query: Get user by email
  const  data: user  = useQuery(
    queryKey: ['users', 'by-email', email],
    queryFn: () => fetchUserByEmail(email),
  );

  // Second query: Get posts (only runs when user exists)
  const  data: posts  = useQuery(
    queryKey: ['posts', 'user', user?.id],
    queryFn: () => fetchUserPosts(user!.id),
    enabled: !!user?.id, // Only run if user.id exists
  );

  return <PostList posts=posts />;

Enter fullscreen mode

Exit fullscreen mode



Parallel Queries

Fetch multiple queries simultaneously:

function Dashboard() 
Enter fullscreen mode

Exit fullscreen mode



useQueries for Dynamic Lists

When you need to fetch variable number of queries:

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

function MultipleUsers( userIds :  userIds: number[] ) 
  const userQueries = useQueries(
    queries: userIds.map(id => userQueryOptions(id)),
    // NEW in v5: Combine results
    combine: results => (
      data: results.map(r => r.data),
      isPending: results.some(r => r.isPending),
    ),
  );

  return (
    <div>
      userQueries.data.map((user, idx) => (
        <UserCard key=userIds[idx] user=user />
      ))
    </div>
  );

Enter fullscreen mode

Exit fullscreen mode



Prefetching

Prefetch data before it’s needed:

import  useQueryClient  from '@tanstack/react-query';

function PostPreview( postId :  postId: number ) 
  const queryClient = useQueryClient();

  // Prefetch on hover
  const handleMouseEnter = () => 
    queryClient.prefetchQuery(postQueryOptions(postId));
  ;

  return (
    <Link to=`/posts/$postId` onMouseEnter=handleMouseEnter>
      View Post
    </Link>
  );


// Prefetch in route loader (React Router)
export const postDetailLoader = async ( params : LoaderFunctionArgs) => 
  const postId = Number(params.id);
  await queryClient.prefetchQuery(postQueryOptions(postId));
  return null;
;
Enter fullscreen mode

Exit fullscreen mode



Initial Data

Provide initial data to avoid loading states:

function PostDetail( postId :  postId: number ) 
  const queryClient = useQueryClient();

  const  data: post  = useQuery(
    ...postQueryOptions(postId),
    // Use data from posts list if available
    initialData: () => 
      const posts = queryClient.getQueryData(postsQueryOptions().queryKey);
      return posts?.find(p => p.id === postId);
    ,
    // Only use initial data if it's fresh
    initialDataUpdatedAt: () =>
      queryClient.getQueryState(postsQueryOptions().queryKey)?.dataUpdatedAt,
  );

  return <PostCard post=post />;

Enter fullscreen mode

Exit fullscreen mode



Placeholder Data

Show placeholder while loading (NEW behavior in v5):

function PostList( userId :  userId: number ) 
  const  data: posts, isPlaceholderData  = useQuery(
    ...postsQueryOptions( userId ),
    // NEW in v5: keepPreviousData is now placeholderData
    placeholderData: previousData => previousData,
  );

  return (
    <div>
      /* Show visual indicator during background fetch */
      isPlaceholderData && <div className="opacity-50">Updating...</div>
      <PostList posts=posts />
    </div>
  );


// Built-in helper (v5)
import  keepPreviousData  from '@tanstack/react-query';

const  data  = useQuery(
  queryKey: ['posts', userId],
  queryFn: fetchPosts,
  placeholderData: keepPreviousData, // Same as above
);
Enter fullscreen mode

Exit fullscreen mode




✏️ Mutations & Optimistic Updates



Basic Mutation

Create src/queries/posts.mutations.ts:

import  useMutation, useQueryClient  from '@tanstack/react-query';
import  api  from '../api/axios-instance';
import type  Post, CreatePostDto  from '../types/post.types';
import  postsQueryOptions  from './posts.queries';

export function useCreatePost() 
  const queryClient = useQueryClient();

  return useMutation(
    mutationFn: async (newPost: CreatePostDto): Promise<Post> => 
      const  data  = await api.post<Post>('/posts', newPost);
      return data;
    ,
    onSuccess: () => 
      // Invalidate and refetch
      queryClient.invalidateQueries( queryKey: ['posts'] );
    ,
  );


export function useUpdatePost() 
  const queryClient = useQueryClient();

  return useMutation(
    mutationFn: async ( id, ...updates :  id: number  & Partial<Post>) => 
      const  data  = await api.patch<Post>(`/posts/$id`, updates);
      return data;
    ,
    onSuccess: (updatedPost) => 
      // Update single item in cache
      queryClient.setQueryData(
        postQueryOptions(updatedPost.id).queryKey,
        updatedPost
      );
      // Invalidate list
      queryClient.invalidateQueries( queryKey: ['posts'], exact: false );
    ,
  );


export function useDeletePost() 
  const queryClient = useQueryClient();

  return useMutation(
    mutationFn: async (id: number) => 
      await api.delete(`/posts/$id`);
      return id;
    ,
    onSuccess: (deletedId) => 
      // Remove from cache
      queryClient.removeQueries( queryKey: ['posts', deletedId] );
      // Invalidate lists
      queryClient.invalidateQueries( queryKey: ['posts'], exact: false );
    ,
  );

Enter fullscreen mode

Exit fullscreen mode



Using Mutations in Components

import  useCreatePost, useUpdatePost  from '../queries/posts.mutations';

function CreatePostForm() {
  const createPost = useCreatePost();

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => 
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    createPost.mutate(
      
        userId: 1,
        title: formData.get('title') as string,
        body: formData.get('body') as string,
      ,
      
        onSuccess: () => 
          alert('Post created!');
          e.currentTarget.reset();
        ,
        onError: (error) => 
          alert(`Error: $error.message`);
        ,
      
    );
  ;

  return (
    <form onSubmit=handleSubmit>
      <input name="title" placeholder="Title" required />
      <textarea name="body" placeholder="Body" required />
      <button type="submit" disabled=createPost.isPending>
        createPost.isPending ? 'Creating...' : 'Create Post'
      </button>
      createPost.isError && <p>Error: createPost.error.message</p>
    </form>
  );
}
Enter fullscreen mode

Exit fullscreen mode



Optimistic Updates (Simplified in v5!)

TanStack Query v5 makes optimistic updates easier by using mutation variables:

export function useTogglePostLike() 
  const queryClient = useQueryClient();

  return useMutation(
    mutationFn: async (postId: number) => 
      const  data  = await api.post(`/posts/$postId/like`);
      return data;
    ,
    // NEW v5 pattern: Use variables for optimistic UI
    onMutate: async (postId) => 
      // Cancel outgoing refetches
      await queryClient.cancelQueries( queryKey: ['posts', postId] );

      // Snapshot previous value
      const previousPost = queryClient.getQueryData(
        postQueryOptions(postId).queryKey
      );

      // Optimistically update
      queryClient.setQueryData(postQueryOptions(postId).queryKey, (old: Post) => (
        ...old,
        likes: old.likes + 1,
      ));

      // Return context with previous value
      return  previousPost ;
    ,
    onError: (err, postId, context) => 
      // Rollback on error
      queryClient.setQueryData(
        postQueryOptions(postId).queryKey,
        context?.previousPost
      );
    ,
    onSettled: (data, error, postId) => 
      // Always refetch after error or success
      queryClient.invalidateQueries( queryKey: ['posts', postId] );
    ,
  );


// Usage with optimistic UI feedback
function LikeButton( postId :  postId: number ) 
  const toggleLike = useTogglePostLike();

  return (
    <button
      onClick=() => toggleLike.mutate(postId)
      disabled=toggleLike.isPending
    >
      👍 Like toggleLike.variables === postId && '(saving...)'
    </button>
  );

Enter fullscreen mode

Exit fullscreen mode



Advanced: Optimistic Updates with Lists

export function useCreatePostOptimistic() 
  const queryClient = useQueryClient();

  return useMutation(
    mutationFn: async (newPost: CreatePostDto): Promise<Post> => 
      const  data  = await api.post('/posts', newPost);
      return data;
    ,
    onMutate: async (newPost) => 
      await queryClient.cancelQueries( queryKey: ['posts'] );

      const previousPosts = queryClient.getQueryData(postsQueryOptions().queryKey);

      // Optimistic post with temporary ID
      const optimisticPost: Post = 
        id: Date.now(), // Temporary ID
        ...newPost,
      ;

      // Add to list
      queryClient.setQueryData(postsQueryOptions().queryKey, (old: Post[] = []) => [
        optimisticPost,
        ...old,
      ]);

      return  previousPosts ;
    ,
    onError: (err, newPost, context) => 
      queryClient.setQueryData(postsQueryOptions().queryKey, context?.previousPosts);
    ,
    onSuccess: (createdPost) => 
      // Replace optimistic post with real one
      queryClient.setQueryData(postsQueryOptions().queryKey, (old: Post[] = []) =>
        old.map(post => (post.id === createdPost.id ? createdPost : post))
      );
    ,
  );

Enter fullscreen mode

Exit fullscreen mode



useMutationState (NEW in v5!)

Access mutation state globally:

import  useMutationState  from '@tanstack/react-query';

function GlobalLoadingIndicator() 
  // Get all pending mutations
  const pendingMutations = useMutationState(
    filters:  status: 'pending' ,
    select: mutation => (
      mutationKey: mutation.state.mutationKey,
      variables: mutation.state.variables,
    ),
  );

  if (pendingMutations.length === 0) return null;

  return (
    <div className="fixed top-0 right-0 p-4">
      pendingMutations.map((m, i) => (
        <div key=i>Saving m.mutationKey...</div>
      ))
    </div>
  );


// Check if specific mutation is pending
function useIsCreatingPost() 
  const mutations = useMutationState(
    filters:  mutationKey: ['posts', 'create'], status: 'pending' ,
  );
  return mutations.length > 0;

Enter fullscreen mode

Exit fullscreen mode




🚀 Advanced Patterns



Infinite Queries

Pagination made easy:

import  useInfiniteQuery  from '@tanstack/react-query';
import  postsInfiniteQueryOptions  from '../queries/posts.queries';

function InfinitePostList() 
  const 
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
   = useInfiniteQuery(postsInfiniteQueryOptions());

  if (isLoading) return <Spinner />;

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

      <button
        onClick=() => fetchNextPage()
        disabled=!hasNextPage 
      >
        isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
          ? 'Load More'
          : 'No more posts'
      </button>
    </div>
  );


// With intersection observer
import  useInView  from 'react-intersection-observer';

function AutoLoadInfiniteList() 
  const  ref, inView  = useInView();

  const  data, fetchNextPage, hasNextPage, isFetchingNextPage  = useInfiniteQuery(
    postsInfiniteQueryOptions()
  );

  // Auto-fetch when sentinel is in view
  useEffect(() => 
    if (inView && hasNextPage && !isFetchingNextPage) 
      fetchNextPage();
    
  , [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <div>
      data?.pages.map(page =>
        page.map(post => <PostCard key=post.id post=post />)
      )
      /* Sentinel element */
      <div ref=ref>isFetchingNextPage && <Spinner /></div>
    </div>
  );

Enter fullscreen mode

Exit fullscreen mode



Query Cancellation

Cancel queries when component unmounts:

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

const fetchPosts = async ( signal :  signal: AbortSignal ) => 
  const res = await fetch('/api/posts',  signal );
  return res.json();
;

export const postsQueryOptions = () =>
  queryOptions(
    queryKey: ['posts'],
    queryFn: ( signal ) => fetchPosts( signal ),
  );

// Query auto-cancels when component unmounts
function PostList() 
  const  data  = useQuery(postsQueryOptions());
  return <div>/* ... */</div>;

Enter fullscreen mode

Exit fullscreen mode



Retry Logic

Smart retry strategies:

export const postsQueryOptions = () =>
  queryOptions(
    queryKey: ['posts'],
    queryFn: fetchPosts,
    retry: (failureCount, error) => 
      // Don't retry on 404
      if (error.response?.status === 404) return false;
      // Retry up to 3 times for other errors
      return failureCount < 3;
    ,
    retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
  );
Enter fullscreen mode

Exit fullscreen mode



Polling

Auto-refetch at intervals:

function LiveDashboard() 
  const  data: stats  = useQuery(
    queryKey: ['dashboard', 'stats'],
    queryFn: fetchStats,
    refetchInterval: 5000, // Refetch every 5 seconds
    refetchIntervalInBackground: true, // Continue when tab is inactive
  );

  return <StatsDisplay stats=stats />;


// Conditional polling
function ConditionalPolling() 
  const [isLive, setIsLive] = useState(false);

  const  data  = useQuery(
    queryKey: ['data'],
    queryFn: fetchData,
    refetchInterval: isLive ? 1000 : false, // Only poll when live mode is on
  );

  return (
    <div>
      <button onClick=() => setIsLive(!isLive)>
        isLive ? 'Stop' : 'Start' Live Updates
      </button>
      <DataDisplay data=data />
    </div>
  );

Enter fullscreen mode

Exit fullscreen mode




⚡ Performance Optimization



1. Selective Re-renders with select

Transform data to prevent unnecessary re-renders:

// Only re-render when user name changes
function UserName( userId :  userId: number ) 
  const  data: name  = useQuery(
    ...userQueryOptions(userId),
    select: user => user.name, // Only subscribe to name changes
  );

  return <div>name</div>;


// Extract specific fields
function PostTitles() 
  const  data: titles  = useQuery(
    ...postsQueryOptions(),
    select: posts => posts.map(p => p.title),
  );

  return <ul>titles?.map(title => <li key=title>title</li>)</ul>;

Enter fullscreen mode

Exit fullscreen mode



2. Structural Sharing

TanStack Query automatically prevents re-renders if data shape doesn’t change:

// Even if refetch runs, component won't re-render if data is identical
const  data  = useQuery(
  queryKey: ['todos'],
  queryFn: fetchTodos,
  refetchInterval: 1000, // Refetch every second
  // But only re-render when data actually changes
);
Enter fullscreen mode

Exit fullscreen mode



3. Query Splitting

Split large queries into smaller, focused ones:

// ❌ Bad: Fetch everything
const  data: user  = useQuery(
  queryKey: ['user', id],
  queryFn: () => fetchUserWithEverything(id), // Posts, comments, profile, settings...
);

// ✅ Good: Split into focused queries
const  data: user  = useQuery(userQueryOptions(id));
const  data: posts  = useQuery(userPostsQueryOptions(id));
const  data: settings  = useQuery(userSettingsQueryOptions(id));
Enter fullscreen mode

Exit fullscreen mode



4. Prefetching Strategies

// Prefetch on hover
function PostLink( postId :  postId: number ) 
  const queryClient = useQueryClient();

  return (
    <Link
      to=`/posts/$postId`
      onMouseEnter=() => queryClient.prefetchQuery(postQueryOptions(postId))
    >
      View Post
    </Link>
  );


// Prefetch next page in infinite query
function InfiniteList() {
  const  data, hasNextPage, fetchNextPage  = useInfiniteQuery(
    postsInfiniteQueryOptions()
  );

  // Prefetch next page when user scrolls to 80%
  useEffect(() => 
    if (hasNextPage) 
      const handleScroll = () => 
        const scrollPercentage =
          (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;

        if (scrollPercentage > 80) 
          fetchNextPage();
        
      ;

      window.addEventListener('scroll', handleScroll);
      return () => window.removeEventListener('scroll', handleScroll);
    
  , [hasNextPage, fetchNextPage]);

  return <div>/* ... */</div>;
}
Enter fullscreen mode

Exit fullscreen mode



5. Disable Queries When Not Needed

function UserProfile( userId :  userId?: number ) 
  const  data: user  = useQuery(
    ...userQueryOptions(userId!),
    enabled: !!userId, // Don't fetch if userId is undefined
  );

  if (!userId) return <div>No user selected</div>;
  if (!user) return <div>Loading...</div>;
  return <UserCard user=user />;

Enter fullscreen mode

Exit fullscreen mode




🛡️ Error Handling



Component-Level Error Handling

function PostList() 
  const  data, error, isError, refetch  = useQuery(postsQueryOptions());

  if (isError) 
    return (
      <div className="error">
        <p>Failed to load posts: error.message</p>
        <button onClick=() => refetch()>Try Again</button>
      </div>
    );
  

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

Enter fullscreen mode

Exit fullscreen mode



Global Error Handling

Create src/lib/query-client.ts:

import  QueryClient  from '@tanstack/react-query';
import  toast  from 'react-hot-toast';

export const queryClient = new QueryClient({
  defaultOptions: 
    queries: 
      onError: (error) => 
        toast.error(`Query error: $error.message`);
      ,
    ,
    mutations: 
      onError: (error) => 
        toast.error(`Mutation error: $error.message`);
      ,
    ,
  ,
});
Enter fullscreen mode

Exit fullscreen mode



Error Boundaries (Recommended Pattern)

Create src/components/ErrorBoundary.tsx:

import  Component, ReactNode  from 'react';
import  useQueryErrorResetBoundary  from '@tanstack/react-query';

interface Props 
  children: ReactNode;
  fallback?: (error: Error, reset: () => void) => ReactNode;


class ErrorBoundaryClass extends Component<Props,  hasError: boolean; error?: Error > 
  constructor(props: Props) 
    super(props);
    this.state =  hasError: false ;
  

  static getDerivedStateFromError(error: Error) 
    return  hasError: true, error ;
  

  render() 
    if (this.state.hasError) 

    return this.props.children;
  


export function ErrorBoundary( children, fallback : Props) {
  const  reset  = useQueryErrorResetBoundary();

  return (
    <ErrorBoundaryClass
      fallback=(error, resetError) => 
        return (
          fallback?.(error, () => 
            reset();
            resetError();
          ) 
    >
      children
    </ErrorBoundaryClass>
  );
}
Enter fullscreen mode

Exit fullscreen mode

Usage:

function App() 
  return (
    <QueryClientProvider client=queryClient>
      <ErrorBoundary>
        <Suspense fallback=<Loading />>
          <Posts />
        </Suspense>
      </ErrorBoundary>
    </QueryClientProvider>
  );

Enter fullscreen mode

Exit fullscreen mode



Typed Error Handling

import axios,  AxiosError  from 'axios';

interface ApiError 
  message: string;
  code: string;
  statusCode: number;


export const postsQueryOptions = () =>
  queryOptions<Post[], AxiosError<ApiError>>(
    queryKey: ['posts'],
    queryFn: async () => 
      const  data  = await api.get<Post[]>('/posts');
      return data;
    ,
  );

// Usage with typed error
function PostList() 
  const  data, error  = useQuery(postsQueryOptions());

  if (error) 
    // TypeScript knows error is AxiosError<ApiError>
    return <div>Error error.response?.data.code: error.response?.data.message</div>;
  

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

Enter fullscreen mode

Exit fullscreen mode




🧪 Testing



Setup Testing Utilities

Create src/test/utils.tsx:

import  ReactNode  from 'react';
import  render, RenderOptions  from '@testing-library/react';
import  QueryClient, QueryClientProvider  from '@tanstack/react-query';

export function createTestQueryClient() 
  return new QueryClient(
    defaultOptions: 
      queries: 
        retry: false,
        gcTime: Infinity,
      ,
      mutations: 
        retry: false,
      ,
    ,
  );


interface WrapperProps 
  children: ReactNode;


export function createWrapper() 
  const testQueryClient = createTestQueryClient();

  return ( children : WrapperProps) => (
    <QueryClientProvider client=testQueryClient>children</QueryClientProvider>
  );


export function renderWithClient(
  ui: React.ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) 
  const testQueryClient = createTestQueryClient();

  return render(ui, 
    wrapper: ( children ) => (
      <QueryClientProvider client=testQueryClient>children</QueryClientProvider>
    ),
    ...options,
  );

Enter fullscreen mode

Exit fullscreen mode



Testing Queries

import  renderHook, waitFor  from '@testing-library/react';
import  createWrapper  from './test/utils';
import  useQuery  from '@tanstack/react-query';
import  postsQueryOptions  from './queries/posts.queries';

describe('Posts Query', () => 
  it('should fetch posts successfully', async () => 
    const  result  = renderHook(() => useQuery(postsQueryOptions()), 
      wrapper: createWrapper(),
    );

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

    expect(result.current.data).toHaveLength(100);
    expect(result.current.data[0]).toHaveProperty('title');
  );

  it('should handle errors', async () => 
    // Mock API error
    vi.spyOn(api, 'get').mockRejectedValueOnce(new Error('Network error'));

    const  result  = renderHook(() => useQuery(postsQueryOptions()), 
      wrapper: createWrapper(),
    );

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

    expect(result.current.error?.message).toBe('Network error');
  );
);
Enter fullscreen mode

Exit fullscreen mode



Testing Mutations

import  renderHook, waitFor  from '@testing-library/react';
import  useCreatePost  from './queries/posts.mutations';
import  createWrapper  from './test/utils';

describe('Create Post Mutation', () => 
  it('should create a post', async () => 
    const  result  = renderHook(() => useCreatePost(), 
      wrapper: createWrapper(),
    );

    result.current.mutate(
      userId: 1,
      title: 'Test Post',
      body: 'Test Body',
    );

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

    expect(result.current.data).toMatchObject(
      userId: 1,
      title: 'Test Post',
      body: 'Test Body',
    );
  );
);
Enter fullscreen mode

Exit fullscreen mode



Testing Components

import  screen  from '@testing-library/react';
import  renderWithClient  from './test/utils';
import  PostList  from './components/PostList';
import  server  from './mocks/server';
import  rest  from 'msw';

describe('PostList Component', () => 
  it('should display posts', async () => 
    renderWithClient(<PostList />);

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

    await waitFor(() => 
      expect(screen.getByText('Post Title 1')).toBeInTheDocument();
    );
  );

  it('should handle errors', async () => 
    server.use(
      rest.get('/posts', (req, res, ctx) => 
        return res(ctx.status(500), ctx.json( message: 'Server Error' ));
      )
    );

    renderWithClient(<PostList />);

    await waitFor(() => 
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    );
  );
);
Enter fullscreen mode

Exit fullscreen mode




💎 Best Practices



1. Use queryOptions Pattern

DO THIS:

// queries/users.queries.ts
export const userQueryOptions = (id: number) =>
  queryOptions(
    queryKey: ['users', id],
    queryFn: () => fetchUser(id),
  );

// Component
const  data  = useQuery(userQueryOptions(5));

// Prefetch
queryClient.prefetchQuery(userQueryOptions(5));
Enter fullscreen mode

Exit fullscreen mode

NOT THIS:

// Component
const  data  = useQuery(
  queryKey: ['users', id],
  queryFn: () => fetchUser(id),
);

// Different place - keys might drift!
queryClient.prefetchQuery(
  queryKey: ['user', id], // Typo! Different key
  queryFn: () => fetchUser(id),
);
Enter fullscreen mode

Exit fullscreen mode



2. Structure Query Keys Hierarchically

// ✅ Good: Hierarchical, predictable
['posts']                                    // All posts
['posts',  status: 'published' ]          // Filtered posts
['posts', postId]                           // Single post
['posts', postId, 'comments']               // Post comments
['posts', postId, 'comments', commentId]    // Single comment

// ❌ Bad: Flat, unpredictable
['allPosts']
['publishedPosts']
['post-123']
['commentsForPost-123']
Enter fullscreen mode

Exit fullscreen mode



3. Co-locate Query Definitions

src/
  queries/          ← All query definitions here
    users.queries.ts
    posts.queries.ts
    users.mutations.ts
    posts.mutations.ts
Enter fullscreen mode

Exit fullscreen mode



4. Set Appropriate Stale Times

// Fast-changing data (stock prices, live scores)
staleTime: 0  // Always stale, always refetch

// Moderate (user feed, notifications)
staleTime: 1000 * 60  // 1 minute

// Slow-changing (user profile, settings)
staleTime: 1000 * 60 * 5  // 5 minutes

// Rarely changes (app config, translations)
staleTime: 1000 * 60 * 60 // 1 hour or Infinity
Enter fullscreen mode

Exit fullscreen mode



5. Handle Loading States Properly

// ✅ Good: Different states for different scenarios
function PostList() 
  const  data, isPending, isError, isFetching  = useQuery(postsQueryOptions());

  if (isPending) return <Skeleton />; // First load
  if (isError) return <Error />;

  return (
    <div>
      isFetching && <RefreshIndicator /> /* Background refetch */
      <Posts posts=data />
    </div>
  );

Enter fullscreen mode

Exit fullscreen mode



6. Use Suspense for Cleaner Code

// ✅ Cleaner with Suspense
function PostDetail( id :  id: number ) 
  const  data: post  = useSuspenseQuery(postQueryOptions(id));
  // data is NEVER undefined!
  return <PostCard post=post />;


function Page() 
  return (
    <Suspense fallback=<Skeleton />>
      <PostDetail id=5 />
    </Suspense>
  );

Enter fullscreen mode

Exit fullscreen mode



7. Invalidate Strategically

// ✅ Invalidate related queries
mutation.onSuccess = () => 
  // Invalidate all posts queries
  queryClient.invalidateQueries( queryKey: ['posts'] );

  // Invalidate specific post
  queryClient.invalidateQueries( queryKey: ['posts', postId] );

  // Invalidate with filters
  queryClient.invalidateQueries(
    queryKey: ['posts'],
    predicate: query => query.state.data?.userId === userId,
  );
;
Enter fullscreen mode

Exit fullscreen mode



8. Handle Optimistic Updates Correctly

// ✅ With rollback
const mutation = useMutation(
  onMutate: async (newData) => 
    await queryClient.cancelQueries( queryKey: ['posts'] );
    const previous = queryClient.getQueryData(['posts']);

    queryClient.setQueryData(['posts'], newData);

    return  previous ; // Context for rollback
  ,
  onError: (err, newData, context) => 
    queryClient.setQueryData(['posts'], context?.previous);
  ,
  onSettled: () => 
    queryClient.invalidateQueries( queryKey: ['posts'] );
  ,
);
Enter fullscreen mode

Exit fullscreen mode



9. Use Query Cancellation

// ✅ Cancel on unmount or new fetch
const fetchPosts = async ( signal :  signal: AbortSignal ) => 
  const response = await fetch('/posts',  signal );
  return response.json();
;

export const postsQueryOptions = () =>
  queryOptions(
    queryKey: ['posts'],
    queryFn: ( signal ) => fetchPosts( signal ),
  );
Enter fullscreen mode

Exit fullscreen mode



10. Avoid Over-Fetching with select

// ✅ Only subscribe to what you need
function UserName( userId :  userId: number ) 
  const  data: name  = useQuery(
    ...userQueryOptions(userId),
    select: user => user.name,
  );

  return <span>name</span>;

Enter fullscreen mode

Exit fullscreen mode




🔄 Migration from v4



Key Breaking Changes

  1. Query keys must be arrays
// v4
queryKey: 'users'  // ❌ String not allowed

// v5
queryKey: ['users']  // ✅ Always array
Enter fullscreen mode

Exit fullscreen mode

  1. cacheTimegcTime
// v4
cacheTime: 1000 * 60 * 10

// v5
gcTime: 1000 * 60 * 10
Enter fullscreen mode

Exit fullscreen mode

  1. isLoadingisPending
// v4
if (isLoading) return <Spinner />;

// v5
if (isPending) return <Spinner />;  // No cached data
// or
if (isLoading) return <Spinner />;  // First fetch (isPending && isFetching)
Enter fullscreen mode

Exit fullscreen mode

  1. keepPreviousDataplaceholderData
// v4
keepPreviousData: true

// v5
import  keepPreviousData  from '@tanstack/react-query';
placeholderData: keepPreviousData
Enter fullscreen mode

Exit fullscreen mode

  1. onSuccess/onError removed from queries
// v4
useQuery(
  queryKey: ['users'],
  queryFn: fetchUsers,
  onSuccess: (data) => toast.success('Loaded!'), // ❌ Removed
);

// v5 - Use useEffect instead
const query = useQuery(
  queryKey: ['users'],
  queryFn: fetchUsers,
);

useEffect(() => 
  if (query.isSuccess) 
    toast.success('Loaded!');
  
, [query.isSuccess]);
Enter fullscreen mode

Exit fullscreen mode

  1. Suspense: Use dedicated hooks
// v4
useQuery( 
  suspense: true,  // ❌ Removed
);

// v5
useSuspenseQuery(  // ✅ Dedicated hook
  queryKey: ['users'],
  queryFn: fetchUsers,
);
Enter fullscreen mode

Exit fullscreen mode



Migration Codemod

npx jscodeshift ./src \
  --extensions=ts,tsx \
  --parser=tsx \
  --transform=./node_modules/@tanstack/react-query/build/codemods/src/v5/remove-overloads/remove-overloads.js
Enter fullscreen mode

Exit fullscreen mode




🐛 Troubleshooting



Issue: Query Not Refetching

Problem:

const  data  = useQuery(
  queryKey: ['users'],
  queryFn: fetchUsers,
);
// Data never updates!
Enter fullscreen mode

Exit fullscreen mode

Solutions:

// 1. Check staleTime
staleTime: 0  // Make data immediately stale

// 2. Check refetch settings
refetchOnWindowFocus: true
refetchOnReconnect: true

// 3. Manual invalidation
queryClient.invalidateQueries( queryKey: ['users'] );
Enter fullscreen mode

Exit fullscreen mode



Issue: Infinite Refetching Loop

Problem:

useQuery(
  queryKey: ['users',  filter: someObject ],
  //                   ^^^ New object every render!
);
Enter fullscreen mode

Exit fullscreen mode

Solution:

// Memoize objects in query keys
const filters = useMemo(() => ( status: 'active' ), []);

useQuery(
  queryKey: ['users', filters],  // ✅ Stable reference
);
Enter fullscreen mode

Exit fullscreen mode



Issue: TypeScript Errors with queryOptions

Problem:

const data = queryClient.getQueryData(userQueryOptions(5).queryKey);
// Type error: queryKey doesn't exist
Enter fullscreen mode

Exit fullscreen mode

Solution:

// Add 'as const' to query keys
export const userQueryOptions = (id: number) =>
  queryOptions(
    queryKey: ['users', id] as const,  // ✅ as const
    queryFn: () => fetchUser(id),
  );
Enter fullscreen mode

Exit fullscreen mode



Issue: Stale Data After Mutation

Problem:

// Created new post but list doesn't update
Enter fullscreen mode

Exit fullscreen mode

Solutions:

// Option 1: Invalidate queries
onSuccess: () => 
  queryClient.invalidateQueries( queryKey: ['posts'] );


// Option 2: Update cache directly
onSuccess: (newPost) => 
  queryClient.setQueryData(['posts'], (old = []) => [...old, newPost]);


// Option 3: Refetch
onSuccess: () => 
  queryClient.refetchQueries( queryKey: ['posts'] );

Enter fullscreen mode

Exit fullscreen mode




📚 Resources



Official Documentation



Learning Resources



Tools



Community




🎓 Key Takeaways

  1. Use queryOptions pattern for type-safe, reusable queries
  2. Set appropriate staleTime based on data volatility
  3. Leverage caching – avoid unnecessary refetches
  4. Optimistic updates for better UX
  5. Use Suspense for cleaner loading states
  6. Structure query keys hierarchically for better cache management
  7. Test with proper utilities – always wrap in QueryClientProvider
  8. Monitor with DevTools – understand what’s happening
  9. Handle errors gracefully – component-level and global
  10. Keep queries co-located – maintain in dedicated files



👨‍💻 Author

Rajat




⭐ Support

If this guide helped you:

  • Share with fellow developers
  • Follow for more content

Happy Querying! 🚀

Made with ❤️ by Rajat



Source link