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. cacheTime โ†’ gcTime

// v4 โŒ
cacheTime: 600000

// v5 โœ…
gcTime: 600000
Enter fullscreen mode Exit fullscreen mode

3. isLoading โ†’ isPending

// 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. keepPreviousData โ†’ placeholderData

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