Skip to main content

πŸ”„ Lesson 8.4: React Query (TanStack Query)

Welcome to the data fetching revolution! React Query (now TanStack Query) has fundamentally changed how we think about server state in React applications. If you've ever struggled with loading states, caching, background refetching, or synchronizing server data, React Query is the solution you've been looking for. In this comprehensive lesson, you'll learn how to use React Query to build applications with seamless data fetching, automatic caching, and incredible developer experience.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand the difference between server state and client state
  • Install and configure React Query in a TypeScript project
  • Use queries to fetch and cache data automatically
  • Implement mutations for creating, updating, and deleting data
  • Handle loading, error, and success states elegantly
  • Configure automatic refetching and cache invalidation
  • Implement optimistic updates for instant UI feedback
  • Type queries and mutations properly with TypeScript
  • Use React Query DevTools for debugging
  • Apply React Query best practices and patterns

Estimated Time: 70-85 minutes

Prerequisites: Lessons 8.1-8.3 (State Management, Zustand, Redux Toolkit), Modules 1-4

In This Lesson

πŸ€” Understanding Server State vs Client State

Here's a problem almost every React developer runs into: you fetch some data from an API, drop it in useState, and everything seems fine β€” until you realize you now have to babysit that data. Is it still fresh? Did another component already fetch it? What happens when the user switches tabs and comes back? React Query (now TanStack Query) exists because managing server state is a fundamentally different problem from managing client state, yet for years we reached for the same useState/useEffect hammer for both. Once you see the distinction, you can't unsee it β€” and your data-fetching code gets dramatically simpler.

What is Server State?

πŸ“– Definition

Server state is data that lives on a remote server and is fetched over the network. It is data you don't own: it can change without your knowledge, be updated by other users, and it needs continuous synchronization between the server and your UI. Think of it like a shared Google Doc β€” you're looking at a snapshot, but the real document lives elsewhere and other people are editing it. TanStack Query (still widely called React Query) is a library that manages this borrowed data for you, handling caching, background refresh, deduplication, and optimistic updates automatically.

Server State vs Client State

Server State Client State
Fetched from API/server Created and managed locally
Shared across users Unique to one user/session
Can become stale Always current
Needs caching strategy Direct state management
Requires synchronization No synchronization needed
Examples: User data, posts, products Examples: Modal open, theme, form inputs

The Problem with Traditional Approaches

Before React Query, managing server state typically looked like this:

// ❌ The old way - so much boilerplate!
function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  
  useEffect(() => {
    setIsLoading(true);
    setError(null);
    
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setIsLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setIsLoading(false);
      });
  }, []); // When should we refetch?
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

⚠️ Problems with Manual Fetching

  • Boilerplate: Loading states, error states, success states
  • No Caching: Same data fetched multiple times
  • No Background Updates: Data goes stale
  • Race Conditions: Multiple requests can conflict
  • No Deduplication: Multiple components = multiple requests
  • Manual Refetching: When should we refetch? On focus? On interval?
  • Memory Leaks: Updating state after unmount

How React Query Solves These Problems

With React Query, the same component becomes:

// βœ… The React Query way - simple and powerful!
import { useQuery } from '@tanstack/react-query';

function UserList() {
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(res => res.json())
  });
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

βœ… React Query Benefits

Out of the box, React Query provides:

  • Automatic Caching: Data is cached and shared across components
  • Background Refetching: Keeps data fresh automatically
  • Window Focus Refetching: Updates when user returns to tab
  • Request Deduplication: Multiple requests = one network call
  • Automatic Retries: Failed requests retry automatically
  • Garbage Collection: Old data is cleaned up automatically
  • Optimistic Updates: Update UI before server responds
  • Pagination & Infinite Scroll: Built-in support
  • DevTools: Visualize queries and cache

When to Use React Query

graph TB A["Need data?"] --> B{"Where's it from?"} B -->|"Server/API"| C["Use TanStack Query"] B -->|"Local/Browser"| D["Use useState/Context/Zustand"] C --> E["Automatic caching"] C --> F["Background updates"] C --> G["Loading states"] D --> H["Direct state control"] D --> I["No network overhead"] style C fill:#c8e6c9 style D fill:#e3f2fd style A fill:#f0f0f0

Use React Query for:

  • βœ… Fetching data from REST APIs
  • βœ… GraphQL queries (with plugins)
  • βœ… Data that can become stale
  • βœ… Data shared across components
  • βœ… CRUD operations
  • βœ… Pagination and infinite scroll

Don't use React Query for:

  • ❌ Local UI state (modals, themes, forms)
  • ❌ One-time configuration fetches
  • ❌ Data you don't need to cache
  • ❌ Simple, infrequent API calls

βš™οΈ Installation and Setup

Let's get React Query set up in your React TypeScript project.

Installing React Query

# Install React Query v5 (TanStack Query)
npm install @tanstack/react-query

# Optional: Install DevTools for debugging
npm install @tanstack/react-query-devtools

πŸ’‘ Version Note

React Query was rebranded as TanStack Query starting with v4. The package is now @tanstack/react-query. All functionality works the same, just with a new namespace. We'll use the latest v5 in this lesson.

Setting Up QueryClient

Create and configure a QueryClient with default options:

// src/lib/queryClient.ts

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

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,  // 5 minutes
      gcTime: 1000 * 60 * 10,    // 10 minutes (v5 renamed cacheTime -> gcTime)
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      retry: 1,
    },
  },
});

// Explanation:
// - staleTime: How long until data is considered stale
// - gcTime:    How long to keep UNUSED data in cache before garbage collection
// - refetchOnWindowFocus: Refetch when user returns to the window
// - refetchOnReconnect:   Refetch when the internet reconnects
// - retry:     Number of retry attempts on failure

⚠️ v5 Rename: cacheTime β†’ gcTime

If you're following an older tutorial, watch out: TanStack Query v5 renamed cacheTime to gcTime (short for "garbage collection time"). The old name no longer works. The behavior is identical β€” it just describes the option more accurately, since it controls when inactive data is evicted from the cache, not how long data stays "cached and usable" in general.

Wrapping Your App

Provide the QueryClient to your entire application:

// src/main.tsx (or App.tsx)

import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';

// Create a client
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      retry: 1,
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      
      {/* DevTools - only appears in development */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </React.StrictMode>
);

βœ… React Query DevTools

The DevTools provide an amazing debugging experience:

  • See all queries and their current state
  • Visualize query cache
  • Manually trigger refetches
  • Inspect query data and errors
  • View query timelines
  • Automatically tree-shaken from production builds

Project Structure

Organize your React Query code:

src/
  lib/
    queryClient.ts          // QueryClient configuration
    
  api/
    users.ts               // User-related API functions
    posts.ts               // Post-related API functions
    
  hooks/
    queries/
      useUsers.ts          // User query hooks
      usePosts.ts          // Post query hooks
    mutations/
      useCreateUser.ts     // User mutation hooks
      useUpdatePost.ts     // Post mutation hooks
      
  components/
    users/
      UserList.tsx         // Uses user queries
      CreateUserForm.tsx   // Uses user mutations

Creating API Functions

Separate your API logic from components:

// src/api/users.ts

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

// GET all users
export async function getUsers(): Promise<User[]> {
  const response = await fetch('https://api.example.com/users');
  
  if (!response.ok) {
    throw new Error('Failed to fetch users');
  }
  
  return response.json();
}

// GET single user
export async function getUser(id: string): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${id}`);
  
  if (!response.ok) {
    throw new Error('Failed to fetch user');
  }
  
  return response.json();
}

// POST create user
export async function createUser(userData: Omit<User, 'id'>): Promise<User> {
  const response = await fetch('https://api.example.com/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userData),
  });
  
  if (!response.ok) {
    throw new Error('Failed to create user');
  }
  
  return response.json();
}

// PATCH update user
export async function updateUser(
  id: string, 
  updates: Partial<User>
): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(updates),
  });
  
  if (!response.ok) {
    throw new Error('Failed to update user');
  }
  
  return response.json();
}

// DELETE user
export async function deleteUser(id: string): Promise<void> {
  const response = await fetch(`https://api.example.com/users/${id}`, {
    method: 'DELETE',
  });
  
  if (!response.ok) {
    throw new Error('Failed to delete user');
  }
}

⚠️ Error Handling

Always check response.ok and throw errors for failed requests. React Query will catch these errors and expose them through the error property in your hooks.

πŸ“₯ Queries: Fetching Data

Queries are the core of React Query. They fetch data, cache it, and keep it synchronized with the server.

Basic useQuery

// src/components/UserList.tsx

import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../api/users';

function UserList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: getUsers
  });
  
  if (isLoading) {
    return <div>Loading users...</div>;
  }
  
  if (error) {
    return <div>Error: {error.message}</div>;
  }
  
  return (
    <ul>
      {data?.map(user => (
        <li key={user.id}>
          {user.name} - {user.email}
        </li>
      ))}
    </ul>
  );
}

Understanding Query States

React Query provides detailed state information:

function UserList() {
  const { 
    data,              // The data returned from queryFn
    error,             // Error object if query failed
    isLoading,         // true on first load (no cached data)
    isFetching,        // true whenever fetching (even if cached data exists)
    isError,           // true if query errored
    isSuccess,         // true if query succeeded
    status,            // 'pending' | 'error' | 'success'
    fetchStatus,       // 'fetching' | 'paused' | 'idle'
    refetch,           // Function to manually refetch
  } = useQuery({
    queryKey: ['users'],
    queryFn: getUsers
  });
  
  // Different ways to check loading
  if (isLoading) {
    return <div>Loading for the first time...</div>;
  }
  
  return (
    <div>
      {isFetching && <div>Updating...</div>}
      {/* Render data */}
    </div>
  );
}
stateDiagram-v2 [*] --> Pending: Query starts Pending --> Success: Data received Pending --> Error: Request failed Success --> Fetching: Refetching Error --> Fetching: Retrying Fetching --> Success: Data received Fetching --> Error: Request failed Success --> [*] Error --> [*]

Query with Parameters

// src/components/UserProfile.tsx

import { useQuery } from '@tanstack/react-query';
import { getUser } from '../api/users';

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId], // Include params in key!
    queryFn: () => getUser(userId),
    enabled: !!userId, // Only run if userId exists
  });
  
  if (isLoading) return <div>Loading user...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>User not found</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

πŸ’‘ The enabled Option

Use enabled to conditionally execute queries:

  • Wait for required data before fetching
  • Disable queries based on user permissions
  • Implement dependent queries
  • Prevent unnecessary requests
const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => getUser(userId),
  enabled: !!userId && hasPermission
});

TypeScript Integration

// Strongly typed query
const { data } = useQuery<User[], Error>({
  queryKey: ['users'],
  queryFn: getUsers
});
// data is typed as User[] | undefined
// error is typed as Error | null

// With type inference (recommended)
const { data, error } = useQuery({
  queryKey: ['users'],
  queryFn: getUsers // Return type inferred as User[]
});
// TypeScript infers types from getUsers function

Query Options

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: getUsers,

  // Caching
  staleTime: 1000 * 60 * 5,           // 5 minutes until stale
  gcTime: 1000 * 60 * 10,             // Keep unused data 10 min (was cacheTime in v4)

  // Refetching
  refetchOnMount: true,               // Refetch on mount if data is stale
  refetchOnWindowFocus: true,         // Refetch when window regains focus
  refetchOnReconnect: true,           // Refetch when internet reconnects
  refetchInterval: false,             // Poll every X ms (or false to disable)
  refetchIntervalInBackground: false, // Poll even when window not focused

  // Retry
  retry: 3,                           // Retry failed requests 3 times
  retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),

  // Other
  enabled: true,                      // Enable/disable the query
  placeholderData: [],                // Show while loading (NOT written to cache)
  initialData: [],                    // Seed data (IS written to cache),
  select: (data) => data,             // Transform/derive data before it reaches the component
});

❌ Gone in v5: the per-query onSuccess / onError / onSettled callbacks

Older tutorials pass onSuccess, onError, and onSettled directly to useQuery. TanStack Query v5 removed these callbacks from queries (they still exist on useMutation). The reason: with caching and background refetching, those callbacks fired inconsistently across the many components sharing a query, which caused subtle bugs. If you need a side effect when data arrives, run it in a normal useEffect keyed on the data, or set a global onError on the QueryClient's QueryCache instead.

Reshaping Data with select

Often a component only needs a slice of the response, or wants it transformed. The select option lets you derive exactly what the component needs β€” and React Query is smart enough to only re-render when the selected value changes, not the whole payload.

// Only re-renders when the COUNT changes, not on every user edit
function UserCount() {
  const { data: count } = useQuery({
    queryKey: ['users'],
    queryFn: getUsers,
    select: (users) => users.length, // derive a number from User[]
  });

  return <p>{count} users online</p>;
}

// Sort or map without touching the cached data
const { data: names } = useQuery({
  queryKey: ['users'],
  queryFn: getUsers,
  select: (users) => users.map(u => u.name).sort(),
});

Suspense-Powered Queries with useSuspenseQuery

React 19 pairs beautifully with Suspense for data fetching. TanStack Query v5 ships a first-class useSuspenseQuery hook. Instead of checking isLoading yourself, you let a parent <Suspense> boundary show the fallback and an <ErrorBoundary> catch failures. The big win for TypeScript: because the hook suspends until data exists, data is never undefined β€” no more optional chaining everywhere.

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

function UserList() {
  // No isLoading / error branches here β€” Suspense handles them.
  const { data } = useSuspenseQuery({
    queryKey: ['users'],
    queryFn: getUsers,
  });

  // 'data' is User[], NOT User[] | undefined πŸŽ‰
  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

function Page() {
  return (
    <Suspense fallback={<div>Loading users…</div>}>
      <UserList />
    </Suspense>
  );
}

πŸ’‘ When to reach for useSuspenseQuery

Use it when the component genuinely can't render without the data and you want clean, branch-free code. Stick with regular useQuery when you want to render a partial UI while data streams in, or when the query is conditional (Suspense queries can't be disabled with enabled: false).

βœ… Query Best Practices

  • Always include every dependency of the request in the queryKey
  • Keep query functions pure and predictable
  • Use enabled for conditional and dependent fetching
  • Set an appropriate staleTime based on how volatile the data is
  • Use select to derive data and minimize re-renders
  • Extract query logic into custom hooks for reusability
  • Let TypeScript infer types from your API functions β€” avoid manual generics

πŸ”‘ Query Keys and Caching

Query keys are the backbone of React Query's caching system. Understanding them is crucial for effective data management.

How Query Keys Work

πŸ“– Query Keys

A query key is the address of a piece of data in the cache. Keys are arrays, and React Query compares them deeply and order-independently for objects. Two queries with the same key share the exact same cache entry β€” fetch ['users'] from three components and only one network request goes out. Change any value in the key and React Query treats it as a brand-new query with its own cache slot.

// Simple string key
useQuery({
  queryKey: ['users'],
  queryFn: getUsers
});

// Array with parameters
useQuery({
  queryKey: ['user', userId],
  queryFn: () => getUser(userId)
});

// Complex keys with multiple parameters
useQuery({
  queryKey: ['users', { status: 'active', page: 1 }],
  queryFn: () => getUsers({ status: 'active', page: 1 })
});

// Keys are compared deeply
['users', { page: 1, status: 'active' }] 
  === 
['users', { status: 'active', page: 1 }]  // true!

Query Key Patterns

// βœ… Good: Hierarchical structure
['users']                          // All users
['users', userId]                  // Specific user
['users', userId, 'posts']         // User's posts
['users', userId, 'posts', postId] // Specific post

// βœ… Good: With filters/params
['users', { status: 'active' }]
['posts', { page: 1, limit: 10 }]
['products', { category: 'electronics', sort: 'price' }]

// ❌ Bad: Inconsistent structure
['users']
['userById', userId]  // Should be ['users', userId]
['getUserPosts', userId]  // Should be ['users', userId, 'posts']

Query Key Factory

Create a centralized key factory for consistency:

// src/lib/queryKeys.ts

export const queryKeys = {
  // Users
  users: {
    all: ['users'] as const,
    lists: () => [...queryKeys.users.all, 'list'] as const,
    list: (filters: string) => 
      [...queryKeys.users.lists(), { filters }] as const,
    details: () => [...queryKeys.users.all, 'detail'] as const,
    detail: (id: string) => 
      [...queryKeys.users.details(), id] as const,
  },
  
  // Posts
  posts: {
    all: ['posts'] as const,
    lists: () => [...queryKeys.posts.all, 'list'] as const,
    list: (filters: string) => 
      [...queryKeys.posts.lists(), { filters }] as const,
    details: () => [...queryKeys.posts.all, 'detail'] as const,
    detail: (id: string) => 
      [...queryKeys.posts.details(), id] as const,
  },
};

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

useQuery({
  queryKey: queryKeys.posts.list('published'),
  queryFn: () => getPosts({ status: 'published' })
});

βœ… Benefits of Query Key Factory

  • Consistency: Same structure everywhere
  • Type Safety: TypeScript autocomplete
  • Maintainability: Change keys in one place
  • Invalidation: Easy to target specific queries
  • Documentation: Self-documenting API

Understanding the Cache

graph TB A["Component 1 requests 'users'"] --> B{"In cache?"} B -->|"No"| C["Fetch from server"] B -->|"Yes β€” fresh"| D["Return cached data"] B -->|"Yes β€” stale"| E["Return cached, refetch in background"] C --> F["Store in cache"] E --> F F --> G["Update components"] H["Component 2 requests 'users'"] --> B style D fill:#c8e6c9 style E fill:#fff3cd style C fill:#e3f2fd

Stale Time vs Cache Time

useQuery({
  queryKey: ['users'],
  queryFn: getUsers,
  staleTime: 1000 * 60 * 5,  // 5 minutes
  gcTime: 1000 * 60 * 10      // 10 minutes (was cacheTime in v4)
});

// Timeline:
// t=0:  Fetch data, cache it as "fresh"
// t=5m: Data becomes "stale" but still in cache
//       - On mount, return stale data + refetch in background
// t=10m: Data removed from cache (garbage collected)
//       - On mount, show loading + fetch fresh data

That two-timer model is the single most important idea in React Query's cache. It's worth picturing the whole lifecycle of one cache entry from birth to garbage collection:

stateDiagram-v2 [*] --> Fresh: "queryFn resolves" Fresh --> Stale: "staleTime elapses" Stale --> Fetching: "mount / focus / invalidate" Fetching --> Fresh: "new data resolves" Stale --> Inactive: "no components using it" Fresh --> Inactive: "no components using it" Inactive --> Fetching: "reused before gcTime" Inactive --> [*]: "gcTime elapses (garbage collected)"

Notice that fresh vs. stale controls refetching, while active vs. inactive (plus gcTime) controls memory. The two are independent: data can be stale but still cached for ages, or fresh yet about to be garbage-collected the moment the last component using it unmounts.

staleTime gcTime (cacheTime)
How long data is considered "fresh" How long inactive data stays in cache
Fresh data won't refetch After gcTime, data is removed
Default: 0 (immediately stale) Default: 5 minutes
Use for data that changes slowly Use to control memory usage

πŸ’‘ Choosing staleTime

Set staleTime based on how often your data changes:

  • 0ms (default): Real-time data, social feeds
  • 30s - 1min: Live dashboards, scores
  • 5-10min: User profiles, settings
  • 1hour+: Rarely changing data (countries, categories)
  • Infinity: Static data (won't refetch until invalidated)

πŸ”§ Mutations: Updating Data

While queries are for reading data, mutations are for creating, updating, or deleting data. React Query's useMutation hook provides a powerful abstraction for modifying server state with automatic cache updates and optimistic UI patterns.

πŸ“– Definition

Mutation: A side effect that changes data on the server (POST, PUT, PATCH, DELETE requests). Where a query is a question ("what are the users?"), a mutation is a command ("create this user"). Mutations don't run automatically β€” you fire them explicitly with mutate() β€” and afterward you tell React Query which cached queries to invalidate so the UI resynchronizes with the server.

Basic Mutation Example

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

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

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

// API function
const createUser = async (userData: CreateUserData): Promise<User> => {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userData)
  });
  
  if (!response.ok) {
    throw new Error('Failed to create user');
  }
  
  return response.json();
};

// Component
function CreateUserForm() {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: (data) => {
      // Invalidate and refetch users list
      queryClient.invalidateQueries({ queryKey: ['users'] });
      console.log('User created:', data);
    },
    onError: (error) => {
      console.error('Error creating user:', error);
    }
  });

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

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      
      <button 
        type="submit" 
        disabled={mutation.isPending}
      >
        {mutation.isPending ? 'Creating...' : 'Create User'}
      </button>
      
      {mutation.isError && (
        <div className="error">
          Error: {mutation.error.message}
        </div>
      )}
      
      {mutation.isSuccess && (
        <div className="success">
          User created successfully!
        </div>
      )}
    </form>
  );
}
graph LR A[User submits form] --> B[mutation.mutate called] B --> C[isPending = true] C --> D[API request] D --> E{Success?} E -->|Yes| F[onSuccess callback] E -->|No| G[onError callback] F --> H[Invalidate queries] H --> I[Refetch data] G --> J[Show error] I --> K[isSuccess = true] J --> L[isError = true] style F fill:#c8e6c9 style G fill:#ffcdd2 style C fill:#fff3cd

Mutation States

const mutation = useMutation({ mutationFn: createUser });

// Access mutation states
mutation.isPending;   // true while request is in flight
mutation.isError;     // true if mutation failed
mutation.isSuccess;   // true if mutation succeeded
mutation.isIdle;      // true if mutation hasn't been called yet

mutation.error;       // Error object if failed
mutation.data;        // Response data if successful
mutation.status;      // 'idle' | 'pending' | 'error' | 'success'

// Call the mutation
mutation.mutate(userData);

// Or use async version with Promise
mutation.mutateAsync(userData)
  .then(data => console.log('Success:', data))
  .catch(error => console.error('Error:', error));

Update and Delete Mutations

// Update mutation
const updateUser = async ({ id, data }: { 
  id: string; 
  data: Partial<User> 
}): Promise<User> => {
  const response = await fetch(`/api/users/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  return response.json();
};

const updateMutation = useMutation({
  mutationFn: updateUser,
  onSuccess: (data, variables) => {
    // Invalidate specific user and list
    queryClient.invalidateQueries({ queryKey: ['users'] });
    queryClient.invalidateQueries({ queryKey: ['users', variables.id] });
  }
});

// Delete mutation
const deleteUser = async (userId: string): Promise<void> => {
  await fetch(`/api/users/${userId}`, {
    method: 'DELETE'
  });
};

const deleteMutation = useMutation({
  mutationFn: deleteUser,
  onSuccess: (_, deletedId) => {
    // Remove from cache
    queryClient.setQueryData<User[]>(['users'], (old) => 
      old?.filter(user => user.id !== deletedId)
    );
  }
});

// Usage
<button onClick={() => updateMutation.mutate({ 
  id: user.id, 
  data: { name: 'New Name' } 
})}>
  Update
</button>

<button onClick={() => deleteMutation.mutate(user.id)}>
  Delete
</button>

βœ… Mutation Best Practices

  • Always invalidate affected queries in onSuccess
  • Use mutateAsync when you need Promise-based flow
  • Handle errors gracefully with onError callbacks
  • Show loading states using isPending
  • Disable submit buttons during mutations
  • Reset mutation state when appropriate with mutation.reset()

Mutation with Variables

// Access mutation variables in callbacks
const mutation = useMutation({
  mutationFn: createUser,
  onMutate: async (variables) => {
    // Called before mutation function
    console.log('About to create:', variables);
    
    // Can return context for rollback
    return { timestamp: Date.now() };
  },
  onSuccess: (data, variables, context) => {
    console.log('Created:', data);
    console.log('With input:', variables);
    console.log('Context:', context);
  },
  onError: (error, variables, context) => {
    console.error('Failed to create:', variables);
    console.log('Rollback context:', context);
  },
  onSettled: (data, error, variables, context) => {
    // Called whether success or error
    console.log('Mutation finished');
  }
});

// All callbacks receive:
// - data: response from mutationFn (success only)
// - error: error object (error only)
// - variables: input passed to mutate()
// - context: value returned from onMutate

πŸ”„ Cache Invalidation and Refetching

One of React Query's superpowers is intelligent cache management. Understanding when and how to invalidate queries ensures your UI always displays the most current data without unnecessary network requests.

Query Invalidation Strategies

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

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

  // 1. Invalidate exact query
  queryClient.invalidateQueries({ 
    queryKey: ['users', '123'] 
  });

  // 2. Invalidate all queries starting with key
  queryClient.invalidateQueries({ 
    queryKey: ['users'] 
  });
  // Invalidates: ['users'], ['users', '123'], ['users', 'list'], etc.

  // 3. Invalidate with predicate function
  queryClient.invalidateQueries({
    predicate: (query) => {
      return query.queryKey[0] === 'users' && 
             query.state.data !== undefined;
    }
  });

  // 4. Invalidate multiple query keys
  queryClient.invalidateQueries({ queryKey: ['users'] });
  queryClient.invalidateQueries({ queryKey: ['posts'] });

  // 5. Invalidate and refetch immediately
  await queryClient.invalidateQueries({ 
    queryKey: ['users'],
    refetchType: 'active' // 'active' | 'inactive' | 'all' | 'none'
  });
}
graph TB A[Mutation Success] --> B[Invalidate Queries] B --> C{Query has active observers?} C -->|Yes| D[Mark stale + Refetch immediately] C -->|No| E[Mark stale only] D --> F[Fresh data in cache] E --> G[Will refetch on next mount] H[Component mounts] --> I{Query in cache?} I -->|Fresh| J[Use cached data] I -->|Stale| K[Show cached + Refetch] I -->|Not in cache| L[Fetch new data] style D fill:#c8e6c9 style K fill:#fff3cd style L fill:#e3f2fd

Manual Refetching

function UserProfile({ userId }: { userId: string }) {
  const { data, refetch, isRefetching } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => getUser(userId)
  });

  return (
    <div>
      <h2>{data?.name}</h2>
      
      <button 
        onClick={() => refetch()}
        disabled={isRefetching}
      >
        {isRefetching ? 'Refreshing...' : 'Refresh'}
      </button>
    </div>
  );
}

// Refetch from outside component
const queryClient = useQueryClient();

// Refetch specific query
queryClient.refetchQueries({ queryKey: ['users', '123'] });

// Refetch all queries with key prefix
queryClient.refetchQueries({ queryKey: ['users'] });

// Refetch all active queries
queryClient.refetchQueries({ type: 'active' });

Setting Query Data Directly

const queryClient = useQueryClient();

// 1. Set data directly (overwrite)
queryClient.setQueryData<User>(['users', '123'], {
  id: '123',
  name: 'John Doe',
  email: 'john@example.com'
});

// 2. Update data with function (merge)
queryClient.setQueryData<User>(['users', '123'], (oldData) => {
  if (!oldData) return oldData;
  return { ...oldData, name: 'Updated Name' };
});

// 3. Update list after creating item
const createMutation = useMutation({
  mutationFn: createUser,
  onSuccess: (newUser) => {
    // Add new user to cached list
    queryClient.setQueryData<User[]>(['users'], (old) => {
      return old ? [...old, newUser] : [newUser];
    });
  }
});

// 4. Update list after deleting item
const deleteMutation = useMutation({
  mutationFn: deleteUser,
  onSuccess: (_, deletedId) => {
    // Remove user from cached list
    queryClient.setQueryData<User[]>(['users'], (old) => {
      return old?.filter(user => user.id !== deletedId);
    });
  }
});

πŸ’‘ When to Use Each Strategy

Strategy When to Use
invalidateQueries Default choice - marks stale and refetches active queries
setQueryData When you have the new data and want instant updates
refetchQueries Force immediate refetch regardless of stale status
removeQueries Clear cache entirely (logout, data no longer valid)

Removing Queries from Cache

// Remove specific query
queryClient.removeQueries({ queryKey: ['users', '123'] });

// Remove all queries with prefix
queryClient.removeQueries({ queryKey: ['users'] });

// Clear entire cache
queryClient.clear();

// Example: Clear user data on logout
const logout = () => {
  // Clear all user-related queries
  queryClient.removeQueries({ queryKey: ['users'] });
  queryClient.removeQueries({ queryKey: ['profile'] });
  
  // Or clear everything
  queryClient.clear();
  
  // Then navigate to login
  navigate('/login');
};

⚠️ Common Pitfall: Over-Invalidation

Invalidating too broadly can cause unnecessary refetches:

// ❌ BAD - Invalidates ALL queries
queryClient.invalidateQueries();

// ❌ BAD - Invalidates all user queries after updating one
queryClient.invalidateQueries({ queryKey: ['users'] });

// βœ… GOOD - Only invalidate what changed
queryClient.invalidateQueries({ queryKey: ['users', userId] });

⚑ Optimistic Updates

Optimistic updates make your app feel instant by updating the UI immediately, before waiting for the server response. If the server request fails, React Query automatically rolls back to the previous state.

πŸ“– Definition

Optimistic Update: Updating the UI immediately based on the expected result of an operation, before the server confirms it. You're betting the request will succeed β€” and since it almost always does, the app feels instant. If the bet loses, React Query rolls the cache back to the snapshot you saved. Think of a "like" button that fills in the instant you tap it, long before the network round-trip finishes.

sequenceDiagram participant User participant UI participant Cache participant Server User->>UI: Click "Like" button UI->>Cache: Update likes count (+1) UI->>User: Show liked state immediately UI->>Server: POST /api/like alt Success Server-->>UI: 200 OK Note over UI,Cache: Keep optimistic update else Error Server-->>UI: 500 Error UI->>Cache: Rollback to previous state UI->>User: Show error + unliked state end

Basic Optimistic Update

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

const toggleTodo = async (id: string): Promise<Todo> => {
  const response = await fetch(`/api/todos/${id}/toggle`, {
    method: 'PATCH'
  });
  return response.json();
};

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

  const toggleMutation = useMutation({
    mutationFn: toggleTodo,
    
    // Before mutation starts
    onMutate: async (todoId) => {
      // Cancel outgoing refetches (so they don't overwrite optimistic update)
      await queryClient.cancelQueries({ queryKey: ['todos'] });

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

      // Optimistically update the cache
      queryClient.setQueryData<Todo[]>(['todos'], (old) => {
        return old?.map(todo =>
          todo.id === todoId
            ? { ...todo, completed: !todo.completed }
            : todo
        );
      });

      // Return context with snapshot
      return { previousTodos };
    },
    
    // On error, rollback
    onError: (error, variables, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
      alert('Failed to update todo. Please try again.');
    },
    
    // Always refetch after error or success
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
  });

  return (
    <ul>
      {todos?.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleMutation.mutate(todo.id)}
          />
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

Optimistic Create

const createTodo = async (newTodo: { title: string }): Promise<Todo> => {
  const response = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newTodo)
  });
  return response.json();
};

const createMutation = useMutation({
  mutationFn: createTodo,
  
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    
    const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
    
    // Create optimistic todo with temporary ID
    const optimisticTodo: Todo = {
      id: `temp-${Date.now()}`, // Temporary ID
      title: newTodo.title,
      completed: false
    };
    
    // Add optimistic todo to cache
    queryClient.setQueryData<Todo[]>(['todos'], (old) => {
      return old ? [...old, optimisticTodo] : [optimisticTodo];
    });
    
    return { previousTodos, optimisticTodo };
  },
  
  onSuccess: (newTodo, variables, context) => {
    // Replace temp todo with real one from server
    queryClient.setQueryData<Todo[]>(['todos'], (old) => {
      return old?.map(todo => 
        todo.id === context.optimisticTodo.id ? newTodo : todo
      );
    });
  },
  
  onError: (error, variables, context) => {
    queryClient.setQueryData(['todos'], context?.previousTodos);
  },
  
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  }
});

Optimistic Delete

const deleteTodo = async (id: string): Promise<void> => {
  await fetch(`/api/todos/${id}`, { method: 'DELETE' });
};

const deleteMutation = useMutation({
  mutationFn: deleteTodo,
  
  onMutate: async (todoId) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    
    const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
    
    // Remove todo optimistically
    queryClient.setQueryData<Todo[]>(['todos'], (old) => {
      return old?.filter(todo => todo.id !== todoId);
    });
    
    return { previousTodos };
  },
  
  onError: (error, variables, context) => {
    // Restore deleted todo on error
    queryClient.setQueryData(['todos'], context?.previousTodos);
    alert('Failed to delete todo');
  },
  
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  }
});

βœ… Optimistic Update Checklist

  1. Cancel queries: Prevent refetches from overwriting optimistic state
  2. Snapshot previous state: Save current data for rollback
  3. Update cache optimistically: Show expected result immediately
  4. Return context: Pass snapshot to error/success handlers
  5. Handle errors: Rollback to previous state on failure
  6. Invalidate on settled: Sync with server after success or error

Complex Optimistic Update: Like Button

interface Post {
  id: string;
  title: string;
  likes: number;
  likedBy: string[]; // Array of user IDs
}

const toggleLike = async ({ 
  postId, 
  userId 
}: { 
  postId: string; 
  userId: string 
}): Promise<Post> => {
  const response = await fetch(`/api/posts/${postId}/like`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId })
  });
  return response.json();
};

function LikeButton({ post, userId }: { post: Post; userId: string }) {
  const queryClient = useQueryClient();
  const isLiked = post.likedBy.includes(userId);

  const likeMutation = useMutation({
    mutationFn: toggleLike,
    
    onMutate: async ({ postId, userId }) => {
      await queryClient.cancelQueries({ queryKey: ['posts', postId] });
      
      const previousPost = queryClient.getQueryData<Post>(['posts', postId]);
      
      queryClient.setQueryData<Post>(['posts', postId], (old) => {
        if (!old) return old;
        
        const isCurrentlyLiked = old.likedBy.includes(userId);
        
        return {
          ...old,
          likes: isCurrentlyLiked ? old.likes - 1 : old.likes + 1,
          likedBy: isCurrentlyLiked
            ? old.likedBy.filter(id => id !== userId)
            : [...old.likedBy, userId]
        };
      });
      
      return { previousPost };
    },
    
    onError: (error, variables, context) => {
      if (context?.previousPost) {
        queryClient.setQueryData(['posts', variables.postId], context.previousPost);
      }
    },
    
    onSettled: (data, error, variables) => {
      queryClient.invalidateQueries({ queryKey: ['posts', variables.postId] });
    }
  });

  return (
    <button
      onClick={() => likeMutation.mutate({ postId: post.id, userId })}
      disabled={likeMutation.isPending}
      style={{
        color: isLiked ? 'red' : 'gray',
        opacity: likeMutation.isPending ? 0.6 : 1
      }}
    >
      ❀️ {post.likes}
    </button>
  );
}

⚠️ When NOT to Use Optimistic Updates

  • Critical operations: Financial transactions, medical records
  • Complex validation: When server might reject for many reasons
  • Large payloads: When update logic is complex or data intensive
  • Permission-based: When user might not have permission

In these cases, show loading state and wait for server confirmation.

🎯 Best Practices and Patterns

After mastering the basics of React Query, following these best practices will help you build maintainable, performant applications that leverage React Query's full power.

1. Organize API Functions

// api/users.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

export const usersApi = {
  getAll: async (): Promise<User[]> => {
    const response = await fetch('/api/users');
    if (!response.ok) throw new Error('Failed to fetch users');
    return response.json();
  },
  
  getById: async (id: string): Promise<User> => {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error('Failed to fetch user');
    return response.json();
  },
  
  create: async (data: Omit<User, 'id'>): Promise<User> => {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    if (!response.ok) throw new Error('Failed to create user');
    return response.json();
  },
  
  update: async ({ id, data }: { id: string; data: Partial<User> }): Promise<User> => {
    const response = await fetch(`/api/users/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    if (!response.ok) throw new Error('Failed to update user');
    return response.json();
  },
  
  delete: async (id: string): Promise<void> => {
    const response = await fetch(`/api/users/${id}`, { method: 'DELETE' });
    if (!response.ok) throw new Error('Failed to delete user');
  }
};

2. Create Custom Hooks for Queries

// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi, User } from '../api/users';

// Query keys factory
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: string) => [...userKeys.lists(), { filters }] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

// Get all users
export function useUsers() {
  return useQuery({
    queryKey: userKeys.lists(),
    queryFn: usersApi.getAll,
    staleTime: 1000 * 60 * 5, // 5 minutes
  });
}

// Get single user
export function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => usersApi.getById(id),
    staleTime: 1000 * 60 * 5,
  });
}

// Create user
export function useCreateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: usersApi.create,
    onSuccess: (newUser) => {
      // Optimistically add to list
      queryClient.setQueryData<User[]>(userKeys.lists(), (old) => {
        return old ? [...old, newUser] : [newUser];
      });
      // Invalidate to refetch from server
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}

// Update user
export function useUpdateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: usersApi.update,
    onSuccess: (updatedUser) => {
      // Update specific user cache
      queryClient.setQueryData(
        userKeys.detail(updatedUser.id),
        updatedUser
      );
      // Invalidate list
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}

// Delete user
export function useDeleteUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: usersApi.delete,
    onSuccess: (_, deletedId) => {
      // Remove from list cache
      queryClient.setQueryData<User[]>(userKeys.lists(), (old) => {
        return old?.filter(user => user.id !== deletedId);
      });
      // Remove detail cache
      queryClient.removeQueries({ queryKey: userKeys.detail(deletedId) });
    },
  });
}

3. Configure Global Defaults

// App.tsx or main provider file
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Global defaults for ALL queries
      staleTime: 1000 * 60 * 5,        // 5 minutes
      gcTime: 1000 * 60 * 10,          // 10 minutes (was cacheTime)
      refetchOnWindowFocus: false,     // Don't refetch on tab focus
      refetchOnReconnect: true,        // Refetch on network reconnect
      retry: 1,                        // Retry failed requests once
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
    },
    mutations: {
      // Global defaults for ALL mutations
      retry: 0,                        // Don't retry mutations
      onError: (error) => {
        console.error('Mutation error:', error);
        // Show global error toast
      },
    },
  },
});

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

4. Error Handling Patterns

// Create custom error class
class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public data?: any
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

// Enhanced fetch with error handling
async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, options);
  
  if (!response.ok) {
    const errorData = await response.json().catch(() => ({}));
    throw new ApiError(
      errorData.message || 'Request failed',
      response.status,
      errorData
    );
  }
  
  return response.json();
}

// Use in component with proper error typing
function UserProfile({ userId }: { userId: string }) {
  const { data, error, isError } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchApi<User>(`/api/users/${userId}`)
  });

  if (isError) {
    if (error instanceof ApiError) {
      if (error.status === 404) {
        return <div>User not found</div>;
      }
      if (error.status === 403) {
        return <div>Access denied</div>;
      }
    }
    return <div>Error: {error.message}</div>;
  }

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

5. Prefetching Data

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

function UserList() {
  const queryClient = useQueryClient();
  const { data: users } = useUsers();

  // Prefetch user details on hover
  const prefetchUser = (userId: string) => {
    queryClient.prefetchQuery({
      queryKey: userKeys.detail(userId),
      queryFn: () => usersApi.getById(userId),
      staleTime: 1000 * 60 * 5,
    });
  };

  return (
    <ul>
      {users?.map(user => (
        <li
          key={user.id}
          onMouseEnter={() => prefetchUser(user.id)}
        >
          <Link to={`/users/${user.id}`}>{user.name}</Link>
        </li>
      ))}
    </ul>
  );
}

// Prefetch on route loader
function UserDetailPage() {
  const { userId } = useParams();
  const queryClient = useQueryClient();
  
  useEffect(() => {
    // Prefetch related data
    queryClient.prefetchQuery({
      queryKey: ['posts', userId],
      queryFn: () => getPostsByUser(userId!)
    });
  }, [userId, queryClient]);
  
  const { data } = useUser(userId!);
  
  return <div>{data?.name}</div>;
}

πŸ’‘ React Query Best Practices Summary

  1. Use query keys factory for consistent, typed keys
  2. Create custom hooks to encapsulate query logic
  3. Organize API functions in separate files
  4. Set sensible global defaults in QueryClient
  5. Handle errors properly with typed error classes
  6. Use optimistic updates for better UX
  7. Prefetch on hover/navigation for snappy experience
  8. Invalidate precisely to avoid unnecessary refetches
  9. Use DevTools for debugging cache state
  10. Test with React Query using testing-library

6. Dependent Queries

// Query B depends on data from Query A
function UserPosts({ userId }: { userId: string }) {
  // First query
  const { data: user } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => getUser(userId)
  });

  // Second query depends on first
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => getPosts(user!.id),
    enabled: !!user, // Only run when user exists
  });

  return <div>{posts?.length} posts</div>;
}

// Parallel dependent queries
function Dashboard() {
  const { data: user } = useUser();
  
  // These only run when user exists
  const queries = useQueries({
    queries: [
      {
        queryKey: ['posts', user?.id],
        queryFn: () => getPosts(user!.id),
        enabled: !!user,
      },
      {
        queryKey: ['comments', user?.id],
        queryFn: () => getComments(user!.id),
        enabled: !!user,
      },
      {
        queryKey: ['likes', user?.id],
        queryFn: () => getLikes(user!.id),
        enabled: !!user,
      },
    ],
  });

  const [postsQuery, commentsQuery, likesQuery] = queries;

  if (queries.some(q => q.isLoading)) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <div>Posts: {postsQuery.data?.length}</div>
      <div>Comments: {commentsQuery.data?.length}</div>
      <div>Likes: {likesQuery.data?.length}</div>
    </div>
  );
}

πŸ‹οΈ Hands-On Exercises

Exercise 1: Blog Post Manager

Goal: Build a blog post manager with React Query that supports viewing, creating, updating, and deleting posts.

Requirements:

  1. Fetch and display a list of blog posts
  2. Show loading and error states
  3. Create new posts with a form
  4. Edit existing posts inline
  5. Delete posts with confirmation
  6. Use proper TypeScript types
  7. Implement cache invalidation
  8. Add optimistic updates for like button
πŸ’‘ Hint #1: Project Structure
// types/post.ts
export interface Post {
  id: string;
  title: string;
  content: string;
  author: string;
  likes: number;
  createdAt: string;
}

// api/posts.ts
export const postsApi = {
  getAll: async (): Promise<Post[]> => { /* ... */ },
  getById: async (id: string): Promise<Post> => { /* ... */ },
  create: async (data: Omit<Post, 'id' | 'createdAt'>): Promise<Post> => { /* ... */ },
  update: async (id: string, data: Partial<Post>): Promise<Post> => { /* ... */ },
  delete: async (id: string): Promise<void> => { /* ... */ },
  like: async (id: string): Promise<Post> => { /* ... */ }
};
πŸ’‘ Hint #2: Custom Hooks
// hooks/usePosts.ts
export const postKeys = {
  all: ['posts'] as const,
  lists: () => [...postKeys.all, 'list'] as const,
  details: () => [...postKeys.all, 'detail'] as const,
  detail: (id: string) => [...postKeys.details(), id] as const,
};

export function usePosts() {
  return useQuery({
    queryKey: postKeys.lists(),
    queryFn: postsApi.getAll
  });
}

export function useCreatePost() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: postsApi.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: postKeys.lists() });
    }
  });
}
βœ… Full Solution
// Full solution in next hint...

Try implementing this yourself first! The solution combines all concepts from this lesson.

Exercise 2: Shopping Cart with Optimistic Updates

Goal: Build a shopping cart that updates instantly when adding/removing items.

Requirements:

  1. Display product list from API
  2. Show current cart items
  3. Add items to cart with optimistic update
  4. Remove items from cart with optimistic update
  5. Update quantities with optimistic update
  6. Rollback on error
  7. Show total price (derived from cart)
  8. Persist cart to server
πŸ’‘ Hint: Optimistic Cart Update
const addToCartMutation = useMutation({
  mutationFn: (productId: string) => addToCart(productId),
  
  onMutate: async (productId) => {
    await queryClient.cancelQueries({ queryKey: ['cart'] });
    
    const previousCart = queryClient.getQueryData<CartItem[]>(['cart']);
    
    queryClient.setQueryData<CartItem[]>(['cart'], (old) => {
      const existing = old?.find(item => item.productId === productId);
      if (existing) {
        return old?.map(item =>
          item.productId === productId
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }
      return [...(old || []), { productId, quantity: 1 }];
    });
    
    return { previousCart };
  },
  
  onError: (err, variables, context) => {
    queryClient.setQueryData(['cart'], context?.previousCart);
  },
  
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['cart'] });
  }
});

Exercise 3: Infinite Scroll with React Query

Goal: Implement infinite scrolling for a feed using useInfiniteQuery.

Requirements:

  1. Fetch paginated posts
  2. Load more on scroll or button click
  3. Show loading state for next page
  4. Handle end of data
  5. Implement proper TypeScript types
πŸ’‘ Hint: useInfiniteQuery Structure
import { useInfiniteQuery } from '@tanstack/react-query';

interface Post {
  id: string;
  title: string;
  content: string;
}

interface PostsResponse {
  posts: Post[];
  nextCursor: string | null;
}

const fetchPosts = async ({ pageParam }: { pageParam: string | undefined }): Promise<PostsResponse> => {
  const url = pageParam 
    ? `/api/posts?cursor=${pageParam}`
    : '/api/posts';
  const response = await fetch(url);
  return response.json();
};

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

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <div key={post.id}>{post.title}</div>
          ))}
        </div>
      ))}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

🧠 Knowledge Check Quiz

Question 1: Query vs Mutation

What is the main difference between useQuery and useMutation?

Show Answer

Answer: useQuery is for reading/fetching data (GET requests), while useMutation is for creating, updating, or deleting data (POST, PUT, PATCH, DELETE requests). Queries run automatically and cache results, while mutations only run when explicitly called with mutate().

Question 2: Cache Invalidation

When should you use invalidateQueries vs setQueryData?

Show Answer

Answer:

  • invalidateQueries: Use when you want to mark data as stale and trigger a refetch. Best for most cases where you want fresh data from the server.
  • setQueryData: Use when you already have the new data and want to update the cache immediately without a server request. Perfect for optimistic updates and when mutations return the updated data.

Question 3: Optimistic Updates

What are the key steps in implementing an optimistic update?

Show Answer

Answer: The key steps are:

  1. Cancel queries to prevent refetches from overwriting optimistic state
  2. Snapshot previous state using getQueryData
  3. Update cache optimistically with setQueryData
  4. Return context with the snapshot from onMutate
  5. Rollback on error by restoring previous state in onError
  6. Invalidate queries in onSettled to sync with server

Question 4: Query Keys

Why are query keys important, and what makes a good query key structure?

Show Answer

Answer: Query keys are crucial because they:

  • Uniquely identify cached data
  • Enable precise cache invalidation
  • Support dependency tracking

A good query key structure:

  • Uses arrays: ['users', '123']
  • Orders from general to specific: ['posts', 'published', { page: 1 }]
  • Uses a key factory for consistency
  • Includes all variables that affect the query

Question 5: staleTime vs gcTime

Explain the difference between staleTime and gcTime (formerly cacheTime).

Show Answer

Answer:

  • staleTime: How long data is considered "fresh" (won't refetch). Default is 0 (immediately stale). Use this to control when refetches happen.
  • gcTime: How long unused data stays in cache before garbage collection. Default is 5 minutes. Use this to control memory usage.

Example: With staleTime=5min and gcTime=10min, data stays fresh for 5 minutes, becomes stale but cached for another 5 minutes, then gets removed from cache.

Question 6: When to Use React Query

Should you use React Query for all state management? When is it appropriate vs when should you use useState or another solution?

Show Answer

Answer:

Use React Query for:

  • Server state (data from APIs)
  • Data that's cached and shared across components
  • Data that can become stale
  • Async data fetching

Use useState/useReducer for:

  • UI state (modal open/closed, form input values)
  • Client-only state that never touches the server
  • Temporary state that doesn't need caching
  • Component-local state

Use Zustand/Redux for:

  • Global client state
  • Complex state logic that's not server-related
  • State that needs to persist across route changes

πŸ“ Lesson Summary

What You've Learned

Congratulations! You've completed the React Query lesson. You now understand:

  • βœ… The difference between server state and client state
  • βœ… How to set up React Query with TypeScript
  • βœ… Using useQuery for data fetching with automatic caching
  • βœ… Query keys and cache management strategies
  • βœ… Using useMutation for create, update, delete operations
  • βœ… Cache invalidation patterns and when to use each
  • βœ… Implementing optimistic updates for instant UI feedback
  • βœ… React Query best practices and patterns
  • βœ… Organizing API functions and custom hooks
  • βœ… Error handling with typed errors
  • βœ… Prefetching and dependent queries
  • βœ… Global configuration and DevTools usage

🎯 Key Takeaways

  1. React Query is for server state: Use it for data from APIs, not UI state
  2. Queries auto-cache and refetch: No manual cache management needed
  3. Mutations update server state: Always invalidate affected queries
  4. Optimistic updates make apps feel instant: Update UI before server responds
  5. Query keys are crucial: Use a factory pattern for consistency
  6. Custom hooks encapsulate logic: One hook per resource type
  7. Configure sensible defaults: Set staleTime based on data volatility
  8. Use DevTools for debugging: Visualize cache state and queries

πŸš€ Next Steps

Now that you've mastered React Query, you can:

  • Refactor existing data fetching code to use React Query
  • Build applications with complex server state requirements
  • Implement real-time features with polling or WebSocket integration
  • Optimize performance with prefetching and parallel queries
  • Proceed to Lesson 8.5: Architecture Best Practices
  • Complete the Module 8 Project: Social Media Feed