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

React Query solves a fundamental problem: managing server state is fundamentally different from managing client state, yet we often treat them the same way.

What is Server State?

📖 Definition

Server state is data that lives on a remote server and is fetched over the network. It's data you don't own - it can change without your knowledge, be updated by other users, and requires synchronization between the server and your UI. React Query (now TanStack Query) is a powerful data fetching library that manages server state automatically with caching, background updates, and optimistic updates.

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 React 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
      cacheTime: 1000 * 60 * 10, // 10 minutes
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      retry: 1,
    },
  },
});

// Explanation:
// - staleTime: How long until data is considered stale
// - cacheTime: How long to keep unused data in cache
// - refetchOnWindowFocus: Refetch when user returns to window
// - refetchOnReconnect: Refetch when internet reconnects
// - retry: Number of retry attempts on failure

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
  cacheTime: 1000 * 60 * 10,       // 10 minutes in cache (deprecated in v5, use gcTime)
  gcTime: 1000 * 60 * 10,          // Garbage collection time (v5+)
  
  // Refetching
  refetchOnMount: true,             // Refetch on component mount if stale
  refetchOnWindowFocus: true,       // Refetch when window regains focus
  refetchOnReconnect: true,         // Refetch when internet reconnects
  refetchInterval: false,           // Refetch every X ms (or false to disable)
  refetchIntervalInBackground: false, // Refetch 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 query
  placeholderData: [],              // Show while loading (doesn't go to cache)
  initialData: [],                  // Initial data (goes to cache)
  
  // Callbacks
  onSuccess: (data) => {
    console.log('Query succeeded:', data);
  },
  onError: (error) => {
    console.error('Query failed:', error);
  },
  onSettled: (data, error) => {
    console.log('Query completed');
  }
});

✅ Query Best Practices

  • Always include all dependencies in queryKey
  • Keep query functions pure and predictable
  • Use enabled for conditional fetching
  • Set appropriate staleTime based on data volatility
  • Extract query logic into custom hooks for reusability
  • Let TypeScript infer types from your API functions

🔑 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

Query keys uniquely identify queries in the cache. They can be strings or arrays, and React Query compares them deeply. If two queries have the same key, they share the same cached data.

// 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] 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
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). Mutations trigger cache invalidation and refetching to keep data synchronized.

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 receiving server confirmation. Provides instant feedback and makes apps feel faster.

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