DEV Community

Rajat Parihar
Rajat Parihar

Posted on • Edited on

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

Hello Everyone! 👋

I'm Rajat, and six months ago, I was drowning in React state management.

My components looked like this:

// My "organized" React component 🤦
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users?page=${page}`)
      .then(res => res.json())
      .then(data => {
        setUsers(prev => [...prev, ...data]);
        setHasMore(data.length > 0);
      })
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, [page]);

  // Another component needs this data? Copy-paste everything! 😭
}
Enter fullscreen mode Exit fullscreen mode

My problems:

  • 50+ lines of state management for simple data fetching
  • Loading states everywhere (still forgot them half the time)
  • Stale data (refresh = start over)
  • No caching (same API called 10 times)
  • Copy-pasted this pattern 30+ times

Then I discovered TanStack Query v5:

// Same functionality in 5 lines!
function UserList() {
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],  // Automatic caching by key
    queryFn: () => fetch('/api/users').then(r => r.json())  // That's it!
  });

  // Automatic refetching, caching, background updates, the works! ✨
}
Enter fullscreen mode Exit fullscreen mode

What changed: TanStack Query handles ALL the complexity I was manually coding. Today, I'm going to show you everything I wish I knew on day 1 - with all my mistakes so you don't make them!


🤔 Why TanStack Query Clicked For Me

The Lightbulb Moment

I was building a dashboard. Users table, posts table, comments - you know the drill. My code looked like:

// Component 1: Fetch users
const [users, setUsers] = useState([]);
useEffect(() => { /* fetch logic */ }, []);

// Component 2: Also needs users (5 minutes later)
const [users, setUsers] = useState([]);  // Wait, I already fetched this!
useEffect(() => { /* same fetch logic copy-pasted */ }, []);

// Result: Called /api/users TWICE
// Wrote the SAME code TWICE
// Bugs in BOTH places when I "fixed" one
Enter fullscreen mode Exit fullscreen mode

The realization: I was treating server data like client state. They're NOT the same!

Client state:

  • You control it (counter, form inputs, modal open/closed)
  • Lives in your app
  • You decide when it changes

Server state:

  • You DON'T control it (someone else's database)
  • Lives on a server
  • Can change without you knowing
  • Needs fetching, caching, syncing

TanStack Query's magic: It treats server data as server data!


📚 What This Complete Guide Covers

Full honesty:

  • I'm a student, NOT a React expert (yet! 🤞)
  • This took me 6 months to truly understand
  • I made every mistake possible (documented them all!)
  • If these patterns help me, they'll help you!

What you'll learn (EVERYTHING in this one post):

  • ✅ Complete setup from scratch
  • ✅ Every TanStack Query concept explained
  • ✅ The queryOptions pattern (game-changer!)
  • Real authentication example
  • ✅ Mutations & optimistic updates
  • ✅ Infinite scrolling that actually works
  • ✅ Error handling (global + local)
  • ✅ Performance optimization
  • ✅ Testing strategies
  • Every line of code explained
  • ✅ All my mistakes with fixes
  • ✅ Production-ready patterns

Time investment: One focused weekend
Difficulty: Beginner-friendly (I explain EVERYTHING)
Prerequisites: Basic React (useState, useEffect)

Note: This is the COMPLETE guide - no "Part 2 next week." Everything is here!


⚡ Quick Start (See It Work First!)

I know you want to see it work FIRST, understand later. Let's go!

1. Create React Project

# Using Vite (recommended - SO much faster than CRA)
npm create vite@latest tanstack-query-app -- --template react-ts
#                                                           ↑
#                                            TypeScript template (trust me on this!)

cd tanstack-query-app
Enter fullscreen mode Exit fullscreen mode

Mistake I made: Used Create React App. Vite is 10x faster. Learn from my pain.

2. Install TanStack Query

# The library itself
npm install @tanstack/react-query

# DevTools (THIS WILL SAVE YOUR LIFE - seriously, install it!)
npm install @tanstack/react-query-devtools

# Axios for API calls (better than fetch)
npm install axios
Enter fullscreen mode Exit fullscreen mode

3. Setup (The Magic Config)

Create src/lib/query-client.ts:

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

// This is your "database" of server data in the browser
export const queryClient = new QueryClient({
  defaultOptions: {  // These are DEFAULTS (can override per query)
    queries: {  // Settings for ALL queries
      staleTime: 1000 * 60 * 5,  // 5 minutes - How long data stays "fresh"
      //         ↑    ↑   ↑
      //        ms   sec  min
      // Translation: "Trust this data for 5 minutes before refetching"

      gcTime: 1000 * 60 * 10,  // 10 minutes - Garbage collection time
      // When to delete unused data from memory
      // OLD NAME in v4: cacheTime (they renamed it in v5)

      retry: 3,  // Retry failed requests 3 times before giving up
      // Useful for flaky networks!

      refetchOnWindowFocus: false,  // Don't refetch when I tab back
      // I turned this OFF (annoying during development)
      // You might want it ON in production!

      refetchOnReconnect: true,  // DO refetch when internet comes back
      // This one is useful!
    },
    mutations: {  // Settings for ALL mutations (create/update/delete)
      retry: 1,  // Only retry once (mutations are more risky)
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

What I learned: These defaults are SMART. They prevent:

  • Hammering your API (staleTime)
  • Memory leaks (gcTime)
  • Failed requests (retry)
  • Stale data after network issues (refetchOnReconnect)

4. Wrap Your App

Edit src/main.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';  // The provider
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';  // The magical panel
import { queryClient } from './lib/query-client';  // Our config
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    {/* Wrap EVERYTHING in QueryClientProvider */}
    <QueryClientProvider client={queryClient}>
      {/* ↑ This makes TanStack Query available everywhere */}

      <App />  {/* Your actual app */}

      {/* DevTools - floating panel in corner */}
      <ReactQueryDevtools initialIsOpen={false} />
      {/* ↑ Opens with a button click. SO USEFUL for debugging! */}
      {/* You can see all queries, their states, data, everything! */}
    </QueryClientProvider>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Why wrap everything? React Context pattern - makes queryClient available to all components without prop drilling!

5. Your First Query (The Magic!)

Create src/components/UserList.tsx:

import { useQuery } from '@tanstack/react-query';  // The main hook!

// Define what a User looks like (TypeScript is your friend)
interface User {
  id: number;
  name: string;
  email: string;
}

export function UserList() {
  // THIS IS THE MAGIC LINE
  const { data, isLoading, error } = useQuery({
    // Query key - uniquely identifies this query
    queryKey: ['users'],  // MUST be an array (v5 requirement)
    //         ↑ This is like a cache key
    // Same key = same data = automatic sharing between components!

    // Query function - HOW to fetch the data
    queryFn: async () => {
      const response = await fetch('https://jsonplaceholder.typicode.com/users');

      if (!response.ok) {  // Always check for errors!
        throw new Error('Failed to fetch users');
      }

      return response.json() as Promise<User[]>;
      //     ↑ TypeScript knows data is User[]
    },
  });

  // Loading state - TanStack Query manages this FOR YOU
  if (isLoading) {
    return <div>Loading users...</div>;
    // Shows during first fetch
  }

  // Error state - Also managed FOR YOU
  if (error) {
    return <div>Error: {error.message}</div>;
    // Shows if fetch failed
  }

  // Success state - data is ALWAYS defined here
  return (
    <div>
      <h2>Users</h2>
      <ul>
        {data?.map(user => (  // ?. because TypeScript (but data IS defined here)
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

6. Use It in Your App

Edit src/App.tsx:

import { UserList } from './components/UserList';

function App() {
  return (
    <div className="App">
      <h1>TanStack Query Demo</h1>
      <UserList />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

7. Run It!

npm run dev
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:5173 and:

  1. See users load ✅
  2. Open DevTools (floating button in corner) ✅
  3. See your query, its state, the data ✅
  4. Refresh page - data loads INSTANTLY (cached!) ✅

You just built a React app with automatic:

  • Caching
  • Loading states
  • Error handling
  • Background refetching
  • State sharing

In 20 lines of code! 🎉


🧠 Core Concepts (The "Aha!" Moments)

Query Keys (The Most Important Concept!)

What confused me: "Why are query keys arrays? Why not just strings?"

The answer: Query keys are like file paths on your computer.

// Think of it like a file system
['users']                              // /users (all users)
['users', 5]                          // /users/5 (user #5)
['users', 5, 'posts']                 // /users/5/posts (user 5's posts)
['users', { status: 'active' }]       // /users?status=active (filtered users)
Enter fullscreen mode Exit fullscreen mode

Rules I learned the hard way:

// ✅ GOOD - Consistent keys
['users', userId]  // userId is a number

// ❌ BAD - Inconsistent types
['users', userId]  // Sometimes number, sometimes string!
// Result: TWO different cache entries!
// ['users', 5] ≠ ['users', '5']  // 😱

// ✅ GOOD - Objects are fine (deep equality)
['users', { status: 'active', role: 'admin' }]

// ✅ GOOD - Hierarchical
['projects', projectId, 'tasks', taskId]

// ❌ BAD - Order matters!
['users', 5]  [5, 'users']  // Different keys!
Enter fullscreen mode Exit fullscreen mode

My mistake: I changed key structures mid-project. Had TWO separate caches for the same data. Took me 3 days to debug! 😭


Query States (Understanding the Lifecycle)

v5 changed this! v4 had isLoading, v5 adds isPending. Here's why:

const { 
  isPending,     // ← NEW in v5
  isLoading,     // ← Changed meaning in v5
  isError,
  isSuccess,
  isFetching,
  isRefetching,
  isStale,
  data,
  error
} = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});
Enter fullscreen mode Exit fullscreen mode

The states explained:

// FIRST LOAD (no cached data)
isPending: true    // ← "I have NO data yet"
isLoading: true    // ← "First fetch happening" (isPending + isFetching)
isFetching: true   // ← "Actively fetching"

// AFTER FIRST LOAD (data in cache)
isPending: false   // ← "I have data (even if stale)"
isLoading: false   // ← "Not first load anymore"
isFetching: true   // ← "Background refetch happening"
isRefetching: true // ← "Refetching existing data"
Enter fullscreen mode Exit fullscreen mode

Why this matters:

// ❌ WRONG - Shows spinner on every refetch!
if (isLoading) return <Spinner />;

// ✅ RIGHT - Only shows spinner on FIRST load
if (isPending) return <Spinner />;
if (isError) return <Error error={error} />;

return (
  <div>
    {isFetching && <div className="refresh-indicator">Updating...</div>}
    {/* ↑ Small indicator for background updates */}

    <UserList users={data} />
    {/* ↑ Still shows old data while fetching new */}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

My mistake: Used isLoading everywhere. Every background refetch showed a spinner. Users thought the app was broken! 🤦


Stale Time vs GC Time (The Cache Lifecycle)

This confused me for WEEKS. Here's the mental model that finally clicked:

Fetch → [Fresh Period] → [Stale Period] → [Unused Period] → Deleted
        └─ staleTime ─┘   └──── still in cache ────┘   └─ gcTime ─┘
Enter fullscreen mode Exit fullscreen mode

Example timeline:

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  staleTime: 1000 * 60 * 5,   // 5 minutes
  gcTime: 1000 * 60 * 10,     // 10 minutes
});

// t=0: Fetch happens
//      Data is FRESH ✨
//      Query won't refetch (data is trusted)

// t=5min: staleTime expires
//         Data becomes STALE 🟡
//         Still shows immediately (cached)
//         BUT refetches in background on mount/focus

// t=15min: No component using this query
//          gcTime expires
//          Data DELETED from cache 🗑️
//          Next fetch = brand new request
Enter fullscreen mode Exit fullscreen mode

Real example:

// User profile (changes rarely)
staleTime: 1000 * 60 * 60,  // 1 hour - trust data for long time
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache even longer

// Live stock prices (changes every second)
staleTime: 0,  // Always stale, always refetch
gcTime: 1000 * 60,  // 1 minute - don't keep long

// App config (never changes)
staleTime: Infinity,  // Never stale!
gcTime: Infinity,     // Never delete!
Enter fullscreen mode Exit fullscreen mode

My mistake: Set staleTime: 0 everywhere. Hammered my API. Got rate-limited. Oops! 😅


🎯 The queryOptions Pattern (The Game-Changer!)

This is THE recommended pattern in v5. Once I learned this, everything clicked!

Why queryOptions? (My Journey)

Before queryOptions (v4 style):

// UserList.tsx
const { data } = useQuery({
  queryKey: ['users', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 300000,
});

// UserDetail.tsx (different file)
const { data } = useQuery({
  queryKey: ['users', userId],  // Same key? Did I spell it right?
  queryFn: () => fetchUser(userId),  // Same function? Copy-pasted!
  staleTime: 300000,  // Same config? Probably!
});

// Prefetch somewhere
queryClient.prefetchQuery({
  queryKey: ['user', userId],  // OOPS! Typo! 'user' not 'users'
  queryFn: () => fetchUser(userId),
});
// Result: Prefetched to DIFFERENT key. Doesn't help at all! 😭
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Keys can drift (typos)
  • Functions copy-pasted
  • No type safety
  • Can't reuse easily

After queryOptions (v5 style):

// queries/users.queries.ts (ONE place for EVERYTHING)
import { queryOptions } from '@tanstack/react-query';

export const userQueryOptions = (userId: number) =>
  queryOptions({
    queryKey: ['users', userId] as const,  // Defined ONCE
    queryFn: () => fetchUser(userId),      // Defined ONCE
    staleTime: 300000,                     // Defined ONCE
  });

// UserList.tsx
const { data } = useQuery(userQueryOptions(5));
//                         ↑ Just call the function!

// UserDetail.tsx
const { data } = useQuery(userQueryOptions(5));
//                         ↑ Same function! Can't make mistakes!

// Prefetch
queryClient.prefetchQuery(userQueryOptions(5));
//                         ↑ Same function! Guaranteed correct!

// Type-safe cache access
const user = queryClient.getQueryData(userQueryOptions(5).queryKey);
//    ↑ TypeScript KNOWS this is User | undefined!
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • ✅ Keys defined once (no typos)
  • ✅ Functions defined once (no drift)
  • ✅ Config defined once (consistent)
  • ✅ Type-safe everywhere
  • ✅ Easy to refactor (change in one place)

Complete Real-World Example (Posts API)

Let me show you a REAL production-ready pattern:

Step 1: Setup Axios

Create src/api/axios-instance.ts:

import axios from 'axios';

// Create axios instance with base config
export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'https://jsonplaceholder.typicode.com',
  //       ↑ Use environment variable if available, fallback to placeholder API

  headers: {
    'Content-Type': 'application/json',  // Always send JSON
  },
});

// Request interceptor - runs BEFORE every request
api.interceptors.request.use(
  config => {
    // Add auth token from localStorage
    const token = localStorage.getItem('token');

    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
      //    ↑ Automatically add token to every request!
    }

    return config;  // Must return config
  },
  error => {
    // Handle request errors (rare, but possible)
    return Promise.reject(error);
  }
);

// Response interceptor - runs AFTER every response
api.interceptors.response.use(
  response => response,  // Success - just pass through

  error => {
    // Handle response errors (common!)

    if (error.response?.status === 401) {
      // Unauthorized - token expired or invalid
      localStorage.removeItem('token');  // Clear invalid token
      window.location.href = '/login';   // Redirect to login
      // This runs on EVERY 401, automatically!
    }

    return Promise.reject(error);  // Must reject for error handling
  }
);
Enter fullscreen mode Exit fullscreen mode

Why interceptors are amazing:

  • Write auth logic ONCE
  • Applies to ALL requests
  • Handle errors globally
  • No repetition!

Step 2: Define Types

Create src/types/post.types.ts:

// What we GET from API
export interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

// What we SEND to create
export interface CreatePostDto {  // DTO = Data Transfer Object
  userId: number;
  title: string;
  body: string;
  // No id - server generates it
}

// What we SEND to update
export interface UpdatePostDto {
  title?: string;  // ? = optional
  body?: string;   // Maybe only updating title, not body
}

// Filters for queries
export interface PostFilters {
  userId?: number;    // Filter by user
  search?: string;    // Search in title/body
  status?: 'published' | 'draft';  // Filter by status
}
Enter fullscreen mode Exit fullscreen mode

Why separate types?

  • API shape ≠ Form shape
  • Create ≠ Update (different fields)
  • Type safety for everything!

Step 3: Create Query Options (THE PATTERN)

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 (HOW to fetch)
// ============================================

const fetchPosts = async (filters?: PostFilters): Promise<Post[]> => {
  const { data } = await api.get<Post[]>('/posts', { 
    params: filters  // Axios converts to query string: /posts?userId=1&search=test
  });
  return data;
  // TypeScript knows data is Post[]
};

const fetchPostById = async (id: number): Promise<Post> => {
  const { data } = await api.get<Post>(`/posts/${id}`);
  //    ↑ Destructure data from response (Axios wraps it)
  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
 * 
 * Usage: useQuery(postsQueryOptions({ userId: 1 }))
 */
export const postsQueryOptions = (filters?: PostFilters) =>
  queryOptions({
    // Query key includes filters - different filters = different cache entry
    queryKey: ['posts', filters] as const,
    //         ↑       ↑ Filters in key means automatic cache separation
    //         |       ['posts', {userId:1}] ≠ ['posts', {userId:2}]
    //         | Base key for all posts

    queryFn: () => fetchPosts(filters),
    //       ↑ Arrow function wraps our async function

    staleTime: 1000 * 60 * 5,  // 5 minutes
    // Posts don't change super fast, 5 min is reasonable
  });

/**
 * Fetch a single post by ID
 * 
 * Usage: useQuery(postQueryOptions(5))
 */
export const postQueryOptions = (id: number) =>
  queryOptions({
    queryKey: ['posts', id] as const,
    //         ↑       ↑ Hierarchical key
    //         |       ['posts', 5] = "post #5 in the posts collection"
    //         | Parent key

    queryFn: () => fetchPostById(id),

    staleTime: 1000 * 60 * 5,

    // Only fetch if id is valid (prevents errors on mount)
    enabled: id > 0,
    // If id is 0 or negative, query won't run
    // Useful for dependent queries!
  });

/**
 * Fetch posts by user ID
 * 
 * Usage: useQuery(userPostsQueryOptions(userId))
 */
export const userPostsQueryOptions = (userId: number) =>
  queryOptions({
    // Different structure = different cache entry from postsQueryOptions
    queryKey: ['posts', 'user', userId] as const,
    //         ↑       ↑      ↑
    //         |       |      | User-specific posts
    //         |       | Namespace for user-filtered posts
    //         | Base collection

    queryFn: () => fetchPostsByUser(userId),

    staleTime: 1000 * 60 * 3,  // 3 minutes (faster changing)
    // User's posts might be created/edited more frequently
  });

/**
 * Infinite query for paginated posts
 * 
 * Usage: useInfiniteQuery(postsInfiniteQueryOptions())
 * 
 * NEW in v5: infiniteQueryOptions helper!
 */
export const postsInfiniteQueryOptions = () =>
  infiniteQueryOptions({
    queryKey: ['posts', 'infinite'] as const,
    //         ↑       ↑ Different key = separate cache
    //         |       | Infinite scroll has its own cache
    //         | Same base collection

    queryFn: async ({ pageParam }) => {
      //              ↑ TanStack Query provides this
      // First page: pageParam = initialPageParam (1)
      // Next pages: pageParam = whatever getNextPageParam returns

      const { data } = await api.get<Post[]>('/posts', {
        params: { 
          _page: pageParam,   // Current page number
          _limit: 10          // Posts per page
        },
      });
      return data;
      // Returns ONE page of data
    },

    initialPageParam: 1,  // Start at page 1
    // REQUIRED in v5! (was optional in v4)

    getNextPageParam: (lastPage, allPages) => {
      //                ↑          ↑
      //                |          | Array of all pages fetched so far
      //                | The page we just fetched

      // Logic: If last page had 10 items, there might be more
      return lastPage.length === 10 
        ? allPages.length + 1  // Next page number
        : undefined;           // No more pages (stop fetching)
    },

    staleTime: 1000 * 60 * 5,

    // NEW in v5: Limit pages in cache!
    maxPages: 3,
    // Only keep last 3 pages in cache
    // Prevents memory bloat on infinite scroll
    // Older pages get garbage collected
  });
Enter fullscreen mode Exit fullscreen mode

Key learnings:

  1. Query keys are hierarchical - organize like a file system
  2. Filters in keys - automatic cache separation
  3. as const - makes TypeScript happy (literal types)
  4. Separate files - queries in one place, components in another
  5. JSDoc comments - helps your future self!

Using Query Options in Components

Now the easy part - using them!

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

// ============================================
// Regular Query (handles all states)
// ============================================
function PostList() {
  const { data: posts, isPending, isError, error } = useQuery(
    postsQueryOptions()
    //  ↑ Just call the function! No config needed!
  );

  if (isPending) return <div>Loading posts...</div>;
  // ↑ Shows on first load only

  if (isError) return <div>Error: {error.message}</div>;
  // ↑ Shows if fetch failed

  return (
    <div>
      {posts?.map(post => (  // posts is Post[] | undefined
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

// ============================================
// With Filters (automatic cache separation)
// ============================================
function FilteredPostList({ userId }: { userId: number }) {
  const { data: posts } = useQuery(
    postsQueryOptions({ userId })
    //                  ↑ Different filters = different cache!
    // postsQueryOptions({ userId: 1 }) ≠ postsQueryOptions({ userId: 2 })
  );

  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));
  //                     ↑ useSUSPENSEQuery, not useQuery
  //    ↑ data is NEVER undefined with Suspense!
  // No isPending check needed - Suspense handles it!

  return (
    <article>
      <h1>{post.title}</h1>
      {/* ↑ TypeScript knows post is Post (not Post | undefined) */}

      <p>{post.body}</p>
    </article>
  );
}

// Must wrap in Suspense boundary
function PostDetailPage({ id }: { id: number }) {
  return (
    <Suspense fallback={<PostSkeleton />}>
      {/* ↑ Shows while PostDetail is loading */}

      <PostDetail id={id} />
      {/* ↑ Only renders when data is ready */}
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Suspense benefits:

  • No isPending checks
  • Cleaner code
  • Loading handled at boundary level
  • Data is NEVER undefined

My mistake: Tried Suspense in v4 (experimental). Had weird bugs. v5 is STABLE - use it! ✨


🔍 Advanced Query Patterns

Dependent Queries (Query B needs Query A's data)

Problem: Need to fetch user, THEN fetch their posts.

function UserPosts({ email }: { email: string }) {
  // STEP 1: Get user by email
  const { data: user } = useQuery({
    queryKey: ['users', 'by-email', email],
    //         ↑       ↑ Namespace for email-based queries
    queryFn: () => fetchUserByEmail(email),
  });

  // STEP 2: Get posts (only runs when user exists!)
  const { data: posts } = useQuery({
    queryKey: ['posts', 'user', user?.id],
    //         ↑               ↑ user.id in key
    queryFn: () => fetchUserPosts(user!.id),
    //                            ↑ Non-null assertion (we know it's defined)

    enabled: !!user?.id,  // ← THE MAGIC LINE
    // Only fetch posts if user.id exists
    // Before user loads, this query is PAUSED
  });

  // Handle states
  if (!user) return <div>Loading user...</div>;
  if (!posts) return <div>Loading posts...</div>;

  return <PostList posts={posts} />;
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. Mount component → first query runs
  2. First query finishes → user has data
  3. enabled: !!user?.id becomes true
  4. Second query starts
  5. Both queries complete → render

My mistake: Forgot enabled. Second query ran immediately with undefined id. Got 404 errors! 🤦


Parallel Queries (Fetch multiple things at once)

Problem: Dashboard needs users, posts, and comments.

function Dashboard() {
  // All three queries run IN PARALLEL (not sequential)
  const users = useQuery(usersQueryOptions());
  const posts = useQuery(postsQueryOptions());
  const comments = useQuery(commentsQueryOptions());

  // Check if ANY query is loading
  const isLoading = users.isPending || posts.isPending || comments.isPending;

  if (isLoading) {
    return <Spinner />;
  }

  // All queries succeeded
  return (
    <div>
      <Stats 
        users={users.data} 
        posts={posts.data} 
        comments={comments.data} 
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance:

  • Parallel: ~300ms (all fetch together)
  • Sequential: ~900ms (one after another)
  • 3x faster!

useQueries (Dynamic list of queries)

Problem: Fetch data for multiple users (count unknown at compile time).

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

function MultipleUsers({ userIds }: { userIds: number[] }) {
  // userIds might be [1, 5, 10] or [2, 3, 4, 8, 12] - we don't know!

  const userQueries = useQueries({
    queries: userIds.map(id => userQueryOptions(id)),
    //       ↑ Creates array of query configs
    // If userIds = [1, 5, 10], creates:
    // [
    //   userQueryOptions(1),
    //   userQueryOptions(5),
    //   userQueryOptions(10)
    // ]

    // NEW in v5: Combine results!
    combine: results => ({
      // results is array of query results
      // results[0] = user 1's query
      // results[1] = user 5's query, etc.

      data: results.map(r => r.data),
      // Extract all data: [user1, user5, user10]

      isPending: results.some(r => r.isPending),
      // isPending = true if ANY query is pending
    }),
  });

  if (userQueries.isPending) {
    return <div>Loading users...</div>;
  }

  return (
    <div>
      {userQueries.data.map((user, idx) => (
        // user might be undefined if query failed
        user && <UserCard key={userIds[idx]} user={user} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use cases:

  • Dynamic lists
  • Selected items
  • Batch operations

Prefetching (Load data before it's needed)

Problem: User hovers over link - make next page instant!

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

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

  // Prefetch on hover
  const handleMouseEnter = () => {
    queryClient.prefetchQuery(postQueryOptions(postId));
    //          ↑ Fetches and caches data
    // When user clicks, data is already there!
  };

  return (
    <Link 
      to={`/posts/${postId}`} 
      onMouseEnter={handleMouseEnter}
      //  ↑ Triggers on hover (before click!)
    >
      View Post
    </Link>
  );
}

// With React Router loader
export const postDetailLoader = async ({ params }: LoaderFunctionArgs) => {
  const postId = Number(params.id);

  // Prefetch before component renders
  await queryClient.prefetchQuery(postQueryOptions(postId));

  return null;  // Loader doesn't return data (TanStack Query handles it)
};
Enter fullscreen mode Exit fullscreen mode

Result: Instant page transitions! Users think your app is magic. ✨


Initial Data (Avoid loading states)

Problem: Already have data from list, don't fetch again for detail.

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

  const { data: post } = useQuery({
    ...postQueryOptions(postId),

    // Try to get data from posts list cache
    initialData: () => {
      const posts = queryClient.getQueryData(postsQueryOptions().queryKey);
      //    ↑ Get posts list from cache
      return posts?.find(p => p.id === postId);
      // If post is in list, use it as initial data
      // If not found, returns undefined (fetch normally)
    },

    // Only use initial data if it's fresh
    initialDataUpdatedAt: () => {
      const state = queryClient.getQueryState(postsQueryOptions().queryKey);
      return state?.dataUpdatedAt;
      // If posts list is stale, don't use initial data
    },
  });

  return <PostCard post={post} />;
}
Enter fullscreen mode Exit fullscreen mode

Result: Detail page shows instantly with list data, then updates in background if stale!


Placeholder Data (Show old data while fetching new)

NEW in v5! Renamed from keepPreviousData.

function PostList({ userId }: { userId: number }) {
  const { data: posts, isPlaceholderData } = useQuery({
    ...postsQueryOptions({ userId }),

    // Keep previous data while fetching new
    placeholderData: previousData => previousData,
    //               ↑ Function that receives previous data
    //               ↑ Return it to keep showing it
  });

  return (
    <div>
      {/* Visual indicator during background fetch */}
      {isPlaceholderData && (
        <div className="opacity-50">Updating...</div>
        // ↑ Dims the data to show it's updating
      )}

      <PostList posts={posts} />
      {/* ↑ Shows old data until new data arrives */}
    </div>
  );
}

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

const { data } = useQuery({
  queryKey: ['posts', userId],
  queryFn: fetchPosts,
  placeholderData: keepPreviousData,  // Same as above function
  //               ↑ Convenience helper
});
Enter fullscreen mode Exit fullscreen mode

Use cases:

  • Paginated lists (don't flash empty on page change)
  • Filtered lists (don't flash while applying filter)
  • Search results (show old while searching new)

My mistake: In v4, I used keepPreviousData: true. In v5, it's placeholderData. Migration broke my code! 😅


✏️ Mutations (Create, Update, Delete)

Queries = READ (GET requests)
Mutations = WRITE (POST/PUT/DELETE requests)

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, postQueryOptions } from './posts.queries';

// ============================================
// CREATE Post
// ============================================
export function useCreatePost() {
  const queryClient = useQueryClient();
  //    ↑ Need this to invalidate queries after mutation

  return useMutation({
    // The actual API call
    mutationFn: async (newPost: CreatePostDto): Promise<Post> => {
      const { data } = await api.post<Post>('/posts', newPost);
      //                          ↑ POST request
      return data;  // Returns created post (with id from server)
    },

    // After successful mutation
    onSuccess: () => {
      // Invalidate posts list - forces refetch
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      //          ↑ Marks all queries with key ['posts'] as stale
      //          ↑ Next time component renders, they refetch

      // Result: List updates with new post!
    },
  });
}

// ============================================
// UPDATE Post
// ============================================
export function useUpdatePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ id, ...updates }: { id: number } & Partial<Post>) => {
      //                  ↑ Destructure id from object, rest in updates
      const { data } = await api.patch<Post>(`/posts/${id}`, updates);
      //                          ↑ PATCH = partial update
      return data;
    },

    onSuccess: (updatedPost) => {
      //         ↑ The returned data from mutationFn

      // Update single item in cache immediately
      queryClient.setQueryData(
        postQueryOptions(updatedPost.id).queryKey,
        updatedPost
        // ↑ Directly update cache without refetch
      );

      // Invalidate lists (might be in filtered lists too)
      queryClient.invalidateQueries({ 
        queryKey: ['posts'], 
        exact: false  // Match ['posts'] and ['posts', ...anything]
      });
    },
  });
}

// ============================================
// DELETE Post
// ============================================
export function useDeletePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (id: number) => {
      await api.delete(`/posts/${id}`);
      //        ↑ DELETE request
      return id;  // Return id for use in callbacks
    },

    onSuccess: (deletedId) => {
      // Remove from cache
      queryClient.removeQueries({ 
        queryKey: ['posts', deletedId] 
      });
      //  ↑ Completely remove from cache (not just invalidate)

      // 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';
import { FormEvent } from 'react';

function CreatePostForm() {
  const createPost = useCreatePost();
  //    ↑ Returns mutation object

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const formData = new FormData(e.currentTarget);

    // Trigger mutation
    createPost.mutate(
      // Data to send
      {
        userId: 1,
        title: formData.get('title') as string,
        body: formData.get('body') as string,
      },
      // Callbacks (optional)
      {
        onSuccess: () => {
          alert('Post created! ✅');
          e.currentTarget.reset();  // Clear form
        },
        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}
        //       ↑ Disable during mutation
      >
        {createPost.isPending ? 'Creating...' : 'Create Post'}
        {/* ↑ Show loading text */}
      </button>

      {createPost.isError && (
        <p className="error">Error: {createPost.error.message}</p>
      )}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Mutation states:

createPost.isPending    // Currently running
createPost.isSuccess    // Completed successfully
createPost.isError      // Failed
createPost.data         // Returned data
createPost.error        // Error object
createPost.variables    // What you passed to mutate()
Enter fullscreen mode Exit fullscreen mode

Optimistic Updates (Make UI instant!)

Problem: User clicks "Like". Button updates only after server responds (slow!).

Solution: Update UI immediately, rollback if server fails.

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

  return useMutation({
    mutationFn: async (postId: number) => {
      // Actually call API
      const { data } = await api.post(`/posts/${postId}/like`);
      return data;
    },

    // BEFORE mutation runs (optimistic!)
    onMutate: async (postId) => {
      //             ↑ The variable passed to mutate()

      // 1. Cancel outgoing refetches (don't overwrite our optimistic update)
      await queryClient.cancelQueries({ 
        queryKey: ['posts', postId] 
      });

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

      // 3. Optimistically update UI
      queryClient.setQueryData(
        postQueryOptions(postId).queryKey,
        (old: Post) => ({
          ...old,
          likes: old.likes + 1,  // Increment likes immediately
        })
      );

      // 4. Return context (passed to onError for rollback)
      return { previousPost };
    },

    // If mutation FAILS
    onError: (err, postId, context) => {
      //        ↑    ↑       ↑
      //        |    |       | Context from onMutate
      //        |    | Variables passed to mutate
      //        | Error object

      // Rollback to previous value
      queryClient.setQueryData(
        postQueryOptions(postId).queryKey,
        context?.previousPost  // Restore snapshot
      );

      alert('Failed to like post');  // Show error
    },

    // After success OR error
    onSettled: (data, error, postId) => {
      // Always refetch to get server truth
      queryClient.invalidateQueries({ 
        queryKey: ['posts', postId] 
      });
    },
  });
}

// Usage
function LikeButton({ postId }: { postId: number }) {
  const toggleLike = useTogglePostLike();

  return (
    <button
      onClick={() => toggleLike.mutate(postId)}
      disabled={toggleLike.isPending}
    >
      👍 Like

      {/* Show indicator while saving */}
      {toggleLike.variables === postId && ' (saving...)'}
      {/*  ↑ variables = the postId we're currently mutating */}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Flow:

  1. User clicks → onMutate → UI updates instantly ⚡
  2. API call starts in background
  3. If success → onSettled → refetch (confirm UI is correct)
  4. If error → onError → rollback → show error

Result: Feels instant even on slow networks!

My mistake: Forgot onError rollback. When API failed, UI showed wrong state! 😭


Optimistic Updates with Lists

More complex - updating an item in a list:

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) => {
      // Cancel refetches
      await queryClient.cancelQueries({ queryKey: ['posts'] });

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

      // Create optimistic post with temporary ID
      const optimisticPost: Post = {
        id: Date.now(),  // Temporary ID (will be replaced by server)
        ...newPost,
      };

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

      return { previousPosts };
    },

    onError: (err, newPost, context) => {
      // Rollback entire list
      queryClient.setQueryData(
        postsQueryOptions().queryKey,
        context?.previousPosts
      );
    },

    onSuccess: (createdPost) => {
      // Replace optimistic post with real one (has real ID)
      queryClient.setQueryData(
        postsQueryOptions().queryKey,
        (old: Post[] = []) =>
          old.map(post =>
            post.id === createdPost.id ? createdPost : post
            //  ↑ Find optimistic post by matching ID
          )
      );
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Tricky parts:

  • Temporary ID (must be unique)
  • Replacing optimistic with real
  • Handling rollback of entire list

useMutationState (NEW in v5!)

Problem: Want to show global loading indicator for ALL mutations.

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

function GlobalLoadingIndicator() {
  // Get ALL pending mutations across entire app
  const pendingMutations = useMutationState({
    filters: { status: 'pending' },
    //         ↑ Only pending ones

    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 bg-blue-500 text-white">
      {pendingMutations.map((m, i) => (
        <div key={i}>
          Saving {JSON.stringify(m.mutationKey)}...
        </div>
      ))}
    </div>
  );
}

// Check if specific mutation type is running
function useIsCreatingPost() {
  const mutations = useMutationState({
    filters: { 
      mutationKey: ['posts', 'create'],  // Only create post mutations
      status: 'pending' 
    },
  });

  return mutations.length > 0;
  // Returns true if ANY create post mutation is running
}
Enter fullscreen mode Exit fullscreen mode

Use cases:

  • Global loading indicators
  • Prevent navigation during saves
  • Show "unsaved changes" warning
  • Track mutation history

🚀 Infinite Queries (Pagination Made Easy!)

Problem: Load more posts as user scrolls.

Basic Infinite Query

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

function InfinitePostList() {
  const {
    data,            // All pages fetched so far
    fetchNextPage,   // Function to load more
    hasNextPage,     // Are there more pages?
    isFetchingNextPage,  // Loading next page?
    isLoading,       // First load?
  } = useInfiniteQuery(postsInfiniteQueryOptions());

  if (isLoading) return <Spinner />;

  return (
    <div>
      {/* data.pages is array of pages */}
      {data?.pages.map((page, pageIndex) => (
        <div key={pageIndex}>
          {/* page is array of posts */}
          {page.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}

      {/* Load more button */}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
          ? 'Load More'
          : 'No more posts'}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Data structure:

data = {
  pages: [
    [post1, post2, post3],  // Page 1
    [post4, post5, post6],  // Page 2
    [post7, post8, post9],  // Page 3
  ],
  pageParams: [1, 2, 3],  // Page numbers
}
Enter fullscreen mode Exit fullscreen mode

Auto-load with Intersection Observer

Better UX: Load more automatically when user scrolls near bottom.

import { useInView } from 'react-intersection-observer';
//       ↑ npm install react-intersection-observer
import { useEffect } from 'react';

function AutoLoadInfiniteList() {
  // Intersection observer hook
  const { ref, inView } = useInView({
    threshold: 0,  // Trigger when ANY part visible
  });

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

  // Auto-fetch when sentinel is visible
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
      // Loads next page automatically!
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

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

      {/* Sentinel element - invisible div at bottom */}
      <div ref={ref} style={{ height: 20 }}>
        {isFetchingNextPage && <Spinner />}
      </div>
      {/* ↑ When this scrolls into view, load more! */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. User scrolls down
  2. Sentinel div enters viewport
  3. inView becomes true
  4. useEffect triggers
  5. fetchNextPage() called
  6. Next page loads
  7. Repeat!

Result: Instagram-style infinite scroll! ✨


⚡ Performance Optimization

1. Selective Re-renders with select

Problem: Component re-renders even when unrelated data changes.

// ❌ BAD - Re-renders on ANY user change
function UserName({ userId }: { userId: number }) {
  const { data: user } = useQuery(userQueryOptions(userId));
  // Re-renders even if only user.email changed!

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

// ✅ GOOD - Only re-renders when name changes
function UserName({ userId }: { userId: number }) {
  const { data: name } = useQuery({
    ...userQueryOptions(userId),
    select: user => user.name,
    //      ↑ Transform data before returning
    // Component only "sees" the name
    // If name doesn't change, no re-render!
  });

  return <div>{name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

More examples:

// Extract specific fields
function PostTitles() {
  const { data: titles } = useQuery({
    ...postsQueryOptions(),
    select: posts => posts.map(p => p.title),
    // Only re-renders if titles array changes
    // Not if post bodies, dates, etc. change
  });

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

// Compute derived state
function PostStats() {
  const { data: stats } = useQuery({
    ...postsQueryOptions(),
    select: posts => ({
      total: posts.length,
      published: posts.filter(p => p.published).length,
      drafts: posts.filter(p => !p.published).length,
    }),
    // Only re-renders if these numbers change
  });

  return <div>Total: {stats?.total}</div>;
}
Enter fullscreen mode Exit fullscreen mode

2. Structural Sharing (Automatic!)

TanStack Query is smart: It prevents re-renders if data shape doesn't change.

// Refetch every second
const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  refetchInterval: 1000,  // Refetch every 1 second
});

// But component only re-renders if todos actually changed!
// If API returns same todos, NO re-render
// TanStack Query does deep equality check automatically
Enter fullscreen mode Exit fullscreen mode

How it works:

// First fetch
oldData = [{ id: 1, text: 'Buy milk' }]

// Second fetch (same data)
newData = [{ id: 1, text: 'Buy milk' }]

// TanStack Query: "These are equal!" → No re-render ✨

// Third fetch (different data)
newerData = [{ id: 1, text: 'Buy bread' }]

// TanStack Query: "These are different!" → Re-render
Enter fullscreen mode Exit fullscreen mode

You get this for FREE! No extra code needed.


3. Query Splitting

Problem: Fetching too much data at once.

// ❌ BAD - One giant query
const { data: user } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUserWithEverything(id),
  // Fetches user + posts + comments + profile + settings + friends + ...
  // 500KB response! 😱
  // Takes 3 seconds to load
  // Can't cache parts separately
});

// ✅ GOOD - Split into focused queries
const { data: user } = useQuery(userQueryOptions(id));
// 5KB, loads instantly

const { data: posts } = useQuery(userPostsQueryOptions(id));
// 20KB, loads next

const { data: settings } = useQuery(userSettingsQueryOptions(id));
// 2KB, loads last

// Benefits:
// - Each part cacheable separately
// - Show user immediately while posts load
// - If only settings change, only refetch settings
Enter fullscreen mode Exit fullscreen mode

4. Prefetching Strategies

Problem: Page transitions feel slow.

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

  return (
    <Link
      to={`/posts/${postId}`}
      onMouseEnter={() => {
        // Start fetching on hover (before click!)
        queryClient.prefetchQuery(postQueryOptions(postId));
      }}
    >
      View Post
    </Link>
  );
}
// Result: Page loads instantly on click!

// Strategy 2: Prefetch next page
function InfiniteList() {
  const { data, hasNextPage } = useInfiniteQuery(
    postsInfiniteQueryOptions()
  );

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

      if (percentage > 80 && hasNextPage) {
        fetchNextPage();  // Prefetch before they hit bottom
      }
    };

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

  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,
    //       ↑ Only fetch if userId is defined
    // Prevents errors from fetching undefined
  });

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

  return <UserCard user={user} />;
}

// More examples
const { data } = useQuery({
  ...dataQueryOptions(),
  enabled: isLoggedIn,  // Only fetch if logged in
});

const { data } = useQuery({
  ...dataQueryOptions(),
  enabled: tabIsActive,  // Only fetch if tab is visible
});
Enter fullscreen mode Exit fullscreen mode

🛡️ Error Handling (The Right Way)

Component-Level Errors

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

  if (isError) {
    return (
      <div className="error-container">
        <h3>😢 Failed to load posts</h3>
        <p>{error.message}</p>
        <button onClick={() => refetch()}>Try Again</button>
        {/* ↑ refetch = try the query again */}
      </div>
    );
  }

  return <div>{/* Normal render */}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Global Error Handling

// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';  // Or your toast library

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: (error) => {
        // Show toast for ALL query errors
        toast.error(`Query error: ${error.message}`);
      },
    },
    mutations: {
      onError: (error) => {
        // Show toast for ALL mutation errors
        toast.error(`Mutation error: ${error.message}`);
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Error Boundaries (Recommended!)

// 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.fallback?.(
          this.state.error!,
          () => this.setState({ hasError: false })
        ) || <div>Something went wrong</div>
      );
    }

    return this.props.children;
  }
}

export function ErrorBoundary({ children, fallback }: Props) {
  const { reset } = useQueryErrorResetBoundary();
  //      ↑ Resets ALL queries when error boundary resets

  return (
    <ErrorBoundaryClass
      fallback={(error, resetError) => {
        return (
          fallback?.(error, () => {
            reset();       // Reset queries
            resetError();  // Reset boundary
          }) || (
            <div>
              <h3>Error: {error.message}</h3>
              <button
                onClick={() => {
                  reset();
                  resetError();
                }}
              >
                Try again
              </button>
            </div>
          )
        );
      }}
    >
      {children}
    </ErrorBoundaryClass>
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ErrorBoundary>
        {/* If ANY query throws, boundary catches it */}
        <Suspense fallback={<Loading />}>
          <Posts />
        </Suspense>
      </ErrorBoundary>
    </QueryClientProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Typed Errors (TypeScript)

import axios, { AxiosError } from 'axios';

// Define error shape
interface ApiError {
  message: string;
  code: string;
  statusCode: number;
}

// Type the query
export const postsQueryOptions = () =>
  queryOptions<Post[], AxiosError<ApiError>>({
  //            ↑       ↑ Error type
  //            | Data type
    queryKey: ['posts'],
    queryFn: async () => {
      const { data } = await api.get<Post[]>('/posts');
      return data;
    },
  });

// Use 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}
        {/* ↑ Full autocomplete! */}
      </div>
    );
  }

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

🧪 Testing (Production-Ready)

Setup Testing Utils

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

// Create query client for tests
export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,  // Don't retry in tests
        gcTime: Infinity,  // Keep cache forever in tests
      },
      mutations: {
        retry: false,
      },
    },
  });
}

// Create wrapper component
interface WrapperProps {
  children: ReactNode;
}

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

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

// Render helper
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

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

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

    // Initially pending
    expect(result.current.isPending).toBe(true);

    // Wait for success
    await waitFor(() => 
      expect(result.current.isSuccess).toBe(true)
    );

    // Check data
    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() }
    );

    // Wait for error
    await waitFor(() => 
      expect(result.current.isError).toBe(true)
    );

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

Testing Mutations

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

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

    // Trigger mutation
    result.current.mutate({
      userId: 1,
      title: 'Test Post',
      body: 'Test Body',
    });

    // Wait for success
    await waitFor(() => 
      expect(result.current.isSuccess).toBe(true)
    );

    // Check returned data
    expect(result.current.data).toMatchObject({
      userId: 1,
      title: 'Test Post',
      body: 'Test Body',
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing Components

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

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

    // Check loading state
    expect(screen.getByText('Loading...')).toBeInTheDocument();

    // Wait for posts to load
    await waitFor(() => {
      expect(screen.getByText('Post Title 1')).toBeInTheDocument();
    });
  });

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

    renderWithClient(<PostList />);

    // Wait for error message
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

💎 Best Practices (Lessons Learned)

1. Always Use queryOptions

// ✅ 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));

// Cache access
const user = queryClient.getQueryData(
  userQueryOptions(5).queryKey
);

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

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

2. Structure Keys Hierarchically

// ✅ GOOD - Predictable hierarchy
['posts']                                    // All posts
['posts', { status: 'published' }]          // Filtered
['posts', postId]                           // Single post
['posts', postId, 'comments']               // Post's comments
['posts', postId, 'comments', commentId]    // Single comment

// ❌ BAD - Flat, unpredictable
['allPosts']
['publishedPosts']
['post-123']
['commentsForPost-123']
['comment-456-for-post-123']
Enter fullscreen mode Exit fullscreen mode

Why hierarchy matters:

// Invalidate all posts queries (including nested)
queryClient.invalidateQueries({ 
  queryKey: ['posts'] 
});
// Invalidates:
// - ['posts']
// - ['posts', 5]
// - ['posts', 5, 'comments']
// etc.
Enter fullscreen mode Exit fullscreen mode

3. Set Appropriate Stale Times

// Fast-changing (stock prices, live scores)
staleTime: 0  // 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

// Never changes (static content)
staleTime: Infinity
Enter fullscreen mode Exit fullscreen mode

4. Handle Loading States Properly

// ✅ GOOD - Different UI for different states
function PostList() {
  const { data, isPending, isError, isFetching } = useQuery(
    postsQueryOptions()
  );

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

  return (
    <div>
      {/* Small indicator for background refetch */}
      {isFetching && <RefreshIndicator />}

      {/* Data visible during refetch */}
      <Posts posts={data} />
    </div>
  );
}

// ❌ BAD - Spinner on every refetch
if (isLoading) return <Spinner />;
Enter fullscreen mode Exit fullscreen mode

5. Use Suspense for Cleaner Code

// ✅ With Suspense - cleaner!
function PostDetail({ id }: { id: number }) {
  const { data: post } = useSuspenseQuery(postQueryOptions(id));
  //                     ↑ useSUSPENSEQuery
  // data is NEVER undefined!

  return <PostCard post={post} />;
  // No loading checks needed!
}

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

// ❌ Without Suspense - more code
function PostDetail({ id }: { id: number }) {
  const { data: post, isPending } = useQuery(postQueryOptions(id));

  if (isPending) return <Skeleton />;
  if (!post) return null;

  return <PostCard post={post} />;
}
Enter fullscreen mode Exit fullscreen mode

6. Invalidate Strategically

// ✅ Invalidate related queries
mutation.onSuccess = () => {
  // Exact match - only ['posts']
  queryClient.invalidateQueries({ 
    queryKey: ['posts'],
    exact: true 
  });

  // Prefix match - ['posts', ...anything]
  queryClient.invalidateQueries({ 
    queryKey: ['posts'],
    exact: false  // or omit (default)
  });

  // With predicate - custom logic
  queryClient.invalidateQueries({
    queryKey: ['posts'],
    predicate: query => 
      query.state.data?.userId === userId,
  });
};
Enter fullscreen mode Exit fullscreen mode

7. Use Query Cancellation

// ✅ Support cancellation
const fetchPosts = async ({ signal }: { signal: AbortSignal }) => {
  const response = await fetch('/posts', { signal });
  //                                       ↑ Pass signal to fetch
  return response.json();
};

export const postsQueryOptions = () =>
  queryOptions({
    queryKey: ['posts'],
    queryFn: ({ signal }) => fetchPosts({ signal }),
    //          ↑ TanStack Query provides signal
  });

// Benefit: Cancelled queries don't update state
// Prevents "Can't perform state update on unmounted component" warnings
Enter fullscreen mode Exit fullscreen mode

8. 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,
    // Only re-renders when name changes
  });

  return <span>{name}</span>;
}

// ❌ BAD - Re-renders on any user change
function UserName({ userId }: { userId: number }) {
  const { data: user } = useQuery(userQueryOptions(userId));
  return <span>{user?.name}</span>;
}
Enter fullscreen mode Exit fullscreen mode

🔄 Migrating from v4 to v5

Key Breaking Changes

1. Query keys must be arrays

// v4 ❌
queryKey: 'users'

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

2. cacheTimegcTime

// v4 ❌
cacheTime: 600000

// v5 ✅
gcTime: 600000
Enter fullscreen mode Exit fullscreen mode

3. isLoadingisPending

// v4 (one state for everything)
if (isLoading) return <Spinner />;

// v5 (two states for clarity)
if (isPending) return <Spinner />;  // No cached data
// or
if (isLoading) return <Spinner />;  // First fetch only
Enter fullscreen mode Exit fullscreen mode

4. keepPreviousDataplaceholderData

// v4 ❌
keepPreviousData: true

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

5. No onSuccess/onError in queries

// v4 ❌
useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  onSuccess: (data) => {
    toast.success('Loaded!');
  },
});

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

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

6. Suspense uses dedicated hook

// v4 ❌
useQuery({ 
  suspense: true 
});

// v5 ✅
useSuspenseQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});
Enter fullscreen mode Exit fullscreen mode

🐛 Troubleshooting (I've Seen It All)

Issue: Query Not Refetching

// Problem
const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});
// Data never updates!

// Solution 1: Check staleTime
staleTime: 0  // Make immediately stale

// Solution 2: Manual invalidation
queryClient.invalidateQueries({ queryKey: ['users'] });

// Solution 3: Enable refetch settings
refetchOnWindowFocus: true,
refetchOnReconnect: true,
Enter fullscreen mode Exit fullscreen mode

Issue: Infinite Refetch Loop

// Problem
useQuery({
  queryKey: ['users', { filter: someObject }],
  //                   ↑ New object every render!
});

// Solution: Memoize objects
const filters = useMemo(() => ({ status: 'active' }), []);

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

Issue: TypeScript Errors

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

// Solution: Add 'as const'
export const userQueryOptions = (id: number) =>
  queryOptions({
    queryKey: ['users', id] as const,  // ✅
    queryFn: () => fetchUser(id),
  });
Enter fullscreen mode Exit fullscreen mode

Issue: Stale Data After Mutation

// Problem: Created post but list doesn't update

// Solution 1: Invalidate
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['posts'] });
}

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

// Solution 3: Refetch
onSuccess: () => {
  queryClient.refetchQueries({ queryKey: ['posts'] });
}
Enter fullscreen mode Exit fullscreen mode

🎓 Key Takeaways

What I learned in 6 months:

  1. queryOptions is essential - use it everywhere
  2. staleTime ≠ gcTime - understand the difference
  3. Query keys = cache keys - structure them hierarchically
  4. isPending vs isLoading - v5 made this clearer
  5. Suspense is stable - use it for cleaner code
  6. Optimistic updates - make UI feel instant
  7. select transforms - prevent unnecessary re-renders
  8. DevTools is life - open it, learn from it
  9. Test with proper setup - wrap in QueryClientProvider
  10. Co-locate queries - keep definitions in one place

💬 Let's Connect!

This is the COMPLETE guide - everything in one place!

If this helped you:

  • ❤️ Give it a like
  • 💬 Comment which concept clicked
  • 🔄 Share with friends learning React
  • 📚 Bookmark for reference

Questions? Drop them below! I'll answer every single one! 🙌


Made with ❤️, lots of debugging, and 6 months of "why isn't this caching?!" by Rajat

CS Student | React Developer | Still Learning TanStack Query Features


P.S. - My first TanStack Query app had staleTime: 0 everywhere and no error handling. We all start somewhere! 😅

P.P.S. - The DevTools saved me SO many times. Open it. Explore it. Love it! 🛠️

P.P.P.S. - This took 6 months to truly understand. Don't rush. It's worth it! 🚀

Top comments (0)