Back to Curriculum

React Query and Server State Management

📚 Lesson 18 of 18 ⏱️ 60 min

React Query and Server State Management

60 min

React Query (TanStack Query) is a powerful library for managing server state in React applications.

It provides automatic background refetching, caching, synchronization, and error handling for API calls.

React Query separates server state from client state, making it easier to manage data fetching and caching.

React Query automatically caches data, deduplicates requests, and provides loading and error states out of the box. This eliminates the need to manually manage these concerns.

The library supports optimistic updates, allowing you to update the UI immediately while the server request is in progress, then roll back if it fails.

React Query's query invalidation system ensures data stays fresh by automatically refetching when related data changes, keeping your UI in sync with server state.

Key Concepts

  • React Query manages server state separately from client state.
  • Automatic caching, deduplication, and background refetching.
  • Built-in loading and error states for queries and mutations.
  • Optimistic updates for better user experience.
  • Query invalidation keeps data fresh automatically.

Learning Objectives

Master

  • Setting up React Query with QueryClient and QueryClientProvider
  • Using useQuery for data fetching with automatic caching
  • Implementing mutations with useMutation
  • Handling optimistic updates and error recovery

Develop

  • Understanding server state vs client state
  • Designing data fetching strategies
  • Building responsive UIs with React Query

Tips

  • Configure QueryClient with appropriate staleTime based on your data freshness requirements.
  • Use consistent query key patterns for easier cache management.
  • Implement error boundaries to handle query errors gracefully.
  • Use React Query Devtools in development to inspect queries and cache.

Common Pitfalls

  • Not configuring QueryClient defaults, leading to excessive refetching.
  • Using inconsistent query keys, breaking cache invalidation.
  • Not handling error states properly in mutations.
  • Over-fetching data when more specific queries would suffice.

Summary

  • React Query simplifies server state management with automatic caching.
  • It provides loading and error states out of the box.
  • Optimistic updates improve user experience.
  • Query invalidation keeps data fresh automatically.

Exercise

Implement a comprehensive data fetching system using React Query with caching, mutations, and optimistic updates.

import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 10 * 60 * 1000, // 10 minutes
      retry: 3,
      refetchOnWindowFocus: false
    }
  }
});

// API functions
const api = {
  getUsers: async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    if (!response.ok) throw new Error('Failed to fetch users');
    return response.json();
  },
  
  getUser: async (id) => {
    const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
    if (!response.ok) throw new Error('Failed to fetch user');
    return response.json();
  },
  
  createUser: async (userData) => {
    const response = await fetch('https://jsonplaceholder.typicode.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();
  },
  
  updateUser: async ({ id, ...userData }) => {
    const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(userData)
    });
    if (!response.ok) throw new Error('Failed to fetch user');
    return response.json();
  },
  
  deleteUser: async (id) => {
    const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
      method: 'DELETE'
    });
    if (!response.ok) throw new Error('Failed to delete user');
    return { id };
  }
};

// Custom hooks
function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: api.getUsers,
    select: (data) => data.map(user => ({
      ...user,
      fullName: `${user.name} (${user.username})`
    }))
  });
}

function useUser(id) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => api.getUser(id),
    enabled: !!id
  });
}

function useCreateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: api.createUser,
    onSuccess: (newUser) => {
      // Optimistically update the cache
      queryClient.setQueryData(['users'], (oldData) => {
        return oldData ? [...oldData, newUser] : [newUser];
      });
      
      // Invalidate and refetch users list
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
    onError: (error) => {
      console.error('Failed to create user:', error);
    }
  });
}

function useUpdateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: api.updateUser,
    onMutate: async (updatedUser) => {
      // Cancel any outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['users'] });
      await queryClient.cancelQueries({ queryKey: ['user', updatedUser.id] });
      
      // Snapshot the previous value
      const previousUsers = queryClient.getQueryData(['users']);
      const previousUser = queryClient.getQueryData(['user', updatedUser.id]);
      
      // Optimistically update to the new value
      queryClient.setQueryData(['users'], (oldData) => {
        return oldData ? oldData.map(user => 
          user.id === updatedUser.id ? { ...user, ...updatedUser } : user
        ) : oldData;
      });
      
      queryClient.setQueryData(['user', updatedUser.id], updatedUser);
      
      // Return a context object with the snapshotted value
      return { previousUsers, previousUser };
    },
    onError: (err, updatedUser, context) => {
      // If the mutation fails, use the context returned from onMutate to roll back
      if (context?.previousUsers) {
        queryClient.setQueryData(['users'], context.previousUsers);
      }
      if (context?.previousUser) {
        queryClient.setQueryData(['user', updatedUser.id], context.previousUser);
      }
    },
    onSettled: (data, error, variables) => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ['users'] });
      queryClient.invalidateQueries({ queryKey: ['user', variables.id] });
    }
  });
}

function useDeleteUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: api.deleteUser,
    onSuccess: (deletedUser) => {
      // Remove from cache
      queryClient.setQueryData(['users'], (oldData) => {
        return oldData ? oldData.filter(user => user.id !== deletedUser.id) : oldData;
      });
      
      // Remove individual user cache
      queryClient.removeQueries({ queryKey: ['user', deletedUser.id] });
    }
  });
}

// Components
function UserList() {
  const { data: users, isLoading, error } = useUsers();
  const createUserMutation = useCreateUser();
  const deleteUserMutation = useDeleteUser();
  
  const handleCreateUser = () => {
    const newUser = {
      name: 'New User',
      username: 'newuser',
      email: 'newuser@example.com'
    };
    createUserMutation.mutate(newUser);
  };
  
  if (isLoading) return <div>Loading users...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <div>
      <h2>Users</h2>
      <button onClick={handleCreateUser} disabled={createUserMutation.isPending}>
        {createUserMutation.isPending ? 'Creating...' : 'Add User'}
      </button>
      
      <ul>
        {users?.map(user => (
          <li key={user.id}>
            {user.fullName} - {user.email}
            <button 
              onClick={() => deleteUserMutation.mutate(user.id)}
              disabled={deleteUserMutation.isPending}
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

function UserForm({ userId, onSave }) {
  const { data: user, isLoading } = useUser(userId);
  const updateUserMutation = useUpdateUser();
  const [formData, setFormData] = useState({});
  
  const handleSubmit = (e) => {
    e.preventDefault();
    updateUserMutation.mutate({ id: userId, ...formData });
    onSave?.();
  };
  
  if (isLoading) return <div>Loading user...</div>;
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Name"
        defaultValue={user?.name}
        onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
      />
      <input
        type="email"
        placeholder="Email"
        defaultValue={user?.email}
        onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
      />
      <button type="submit" disabled={updateUserMutation.isPending}>
        {updateUserMutation.isPending ? 'Updating...' : 'Update User'}
      </button>
    </form>
  );
}

// Main App
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="app">
        <h1>React Query Demo</h1>
        <UserList />
      </div>
    </QueryClientProvider>
  );
}

Code Editor

Output