🔧 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

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

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:

🎯 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:

📚 Additional Resources