Welcome back, intrepid React developer! In our previous chapters, we dove deep into managing client-side state using useState, useReducer, and even explored global solutions like Zustand. You’ve built responsive UIs and handled various interactive elements. But what happens when your application needs to talk to the outside world? What about fetching data from APIs, displaying it, and updating it? This is where server-side data fetching comes into play, and it’s a game-changer for any real-world application.

This chapter is your comprehensive guide to mastering server-side data fetching in modern React applications. We’ll explore why traditional methods fall short for complex scenarios and introduce you to TanStack Query (formerly React Query), the industry standard for managing server state as of 2026. You’ll learn how to fetch, cache, update, and invalidate data with confidence, implementing robust patterns like optimistic updates and automatic retries. Get ready to build applications that feel incredibly fast and resilient!

Before we dive in, make sure you’re comfortable with React Hooks (useState, useEffect), basic component composition, and a foundational understanding of asynchronous JavaScript (Promises, async/await). While we’ll touch upon API interactions, a deep knowledge of backend APIs isn’t required – we’ll use a simple mock API for our exercises.

The Challenge of Server State: Why Traditional Methods Fall Short

Imagine building a social media feed. You need to fetch posts, display them, allow users to like them, add comments, and then potentially refetch updated data. If you were to do this with just useState and useEffect, you’d quickly run into a maze of complexities:

  • Loading States: How do you show a spinner while data is fetching?
  • Error Handling: What if the network request fails? How do you display an error message and allow retries?
  • Caching: If a user navigates away from the feed and then back, should you refetch all the data again, or display the old data while checking for new?
  • Background Updates: What if the data on the server changes while the user is looking at it? How do you keep your UI fresh without constant manual refetches?
  • Request Deduplication: If multiple components try to fetch the same data at the same time, should they all make separate network requests?
  • Race Conditions: What if a user clicks “like” multiple times rapidly, or navigates away while a request is in flight?
  • Optimistic Updates: To make the UI feel instant, how can you show a “like” immediately even before the server confirms it? And how do you revert if the server request fails?

These are all common, thorny problems that every production application faces. Trying to solve them manually with useEffect leads to a lot of boilerplate, bugs, and a poor developer experience. This is precisely why specialized libraries for server state management exist.

Introducing TanStack Query (React Query v5)

TanStack Query, often still referred to as React Query, is a powerful data-fetching library that provides a set of hooks for fetching, caching, synchronizing, and updating server state in your React applications. It’s not a general-purpose state manager like Redux or Zustand; it’s specifically designed to tackle the unique challenges of asynchronous server data.

Why is it so popular and recommended for 2026? It essentially provides an “API for your APIs,” abstracting away the complexities mentioned above. It handles:

  • Caching: Keeps your fetched data in memory.
  • Background Refetching: Automatically updates stale data in the background.
  • Request Deduplication: Prevents multiple identical requests.
  • Retries with Exponential Backoff: Automatically retries failed requests intelligently.
  • Loading and Error States: Exposes intuitive isLoading, isError, error flags.
  • Optimistic Updates: Makes your UI feel incredibly fast.
  • Garbage Collection: Automatically cleans up unused cached data.

This means you write significantly less code and get a more robust, performant, and delightful user experience out of the box.

Core Concepts of TanStack Query

Let’s break down the fundamental building blocks of TanStack Query.

  1. QueryClient and QueryClientProvider:

    • The QueryClient is the brain of TanStack Query. It holds the entire cache, manages background updates, and provides configuration for all your queries. You typically create one instance of this client.
    • The QueryClientProvider is a React Context Provider that makes the QueryClient instance available to all components within its tree. You wrap your root React component with it.
  2. Query Keys:

    • Every piece of server data you fetch needs a unique identifier, known as a query key. This key is crucial for TanStack Query to manage caching, refetching, and invalidation.
    • Query keys are always arrays.
    • Simple key: ['todos'] (for fetching a list of todos).
    • Key with parameters: ['todo', todoId] (for fetching a specific todo by its ID). The order of elements in the array matters, and TanStack Query internally serializes these keys to identify unique queries.
  3. useQuery Hook:

    • This is the primary hook for fetching data (typically GET requests).
    • It takes a query key and a function that performs the actual data fetching (your “query function”).
    • It returns an object containing the data, isLoading, isError, error, isFetching, and refetch properties, among others.
  4. useMutation Hook:

    • This hook is used for making changes to data on the server (typically POST, PUT, DELETE requests).
    • It takes a mutation function that performs the server-side update.
    • It returns an object with a mutate function (which you call to trigger the mutation) and state variables like isLoading, isError, error, isSuccess.
    • It also provides callback functions like onSuccess, onError, and onSettled for handling side effects after a mutation.
  5. Caching and Stale-While-Revalidate:

    • TanStack Query automatically caches the results of your queries.
    • It uses a “stale-while-revalidate” strategy. This means that when a query is considered “stale” (after a configurable staleTime), TanStack Query will serve the cached data immediately to the UI while simultaneously fetching fresh data in the background. Once the new data arrives, the UI updates seamlessly. This provides an excellent user experience, reducing perceived loading times.
    • staleTime: How long data is considered fresh before it becomes stale. After this time, the next time the component mounts or the window refocuses, a background refetch will occur. Default is 0 (data is immediately stale).
    • cacheTime: How long inactive (unused) query data remains in the cache before it’s garbage collected. Default is 5 minutes.
  6. Query Invalidation:

    • After a mutation (e.g., adding a new todo), your cached list of todos might be outdated. Query invalidation tells TanStack Query to mark specific query keys as “stale,” forcing a refetch of that data. This is crucial for keeping your UI synchronized with the server.

Step-by-Step Implementation: Building a Smart Todo App

Let’s build a simple Todo application that leverages TanStack Query for all its data fetching and manipulation.

1. Project Setup and Dependencies

First, let’s get our environment ready. We’ll use Vite for a fast React setup and json-server to quickly spin up a mock REST API.

# Create a new React project with Vite
npm create vite@latest my-todo-app -- --template react-ts
cd my-todo-app

# Install TanStack Query v5
npm install @tanstack/react-query@5
npm install @tanstack/react-query-devtools@5 # Optional but highly recommended for debugging

# Install json-server for a mock API
npm install -D [email protected] # Using a specific version for stability

# Open your project in your code editor
code .

Next, create a db.json file in the root of your my-todo-app project. This will be our mock database for json-server.

// db.json
{
  "todos": [
    { "id": "1", "title": "Learn TanStack Query", "completed": false },
    { "id": "2", "title": "Build a React App", "completed": true },
    { "id": "3", "title": "Deploy to Production", "completed": false }
  ]
}

Now, add a script to your package.json to easily run json-server:

// package.json
{
  "name": "my-todo-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "server": "json-server --watch db.json --port 3001" // Add this line!
  },
  "dependencies": {
    "@tanstack/react-query": "^5.20.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@tanstack/react-query-devtools": "^5.20.1",
    "@types/react": "^18.2.55",
    "@types/react-dom": "^18.2.19",
    "@typescript-eslint/eslint-plugin": "^6.21.0",
    "@typescript-eslint/parser": "^6.21.0",
    "@vitejs/plugin-react": "^4.2.1",
    "eslint": "^8.56.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.5",
    "json-server": "^0.17.4",
    "typescript": "^5.2.2",
    "vite": "^5.1.0"
  }
}

Start your mock API server in a separate terminal:

npm run server

You should now have a JSON API running at http://localhost:3001/todos.

2. Setting Up QueryClientProvider

Open src/main.tsx. We need to create an instance of QueryClient and wrap our App component with QueryClientProvider. We’ll also add the Devtools for easy debugging.

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';

// Import QueryClient and QueryClientProvider
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Import ReactQueryDevtools for debugging (optional but highly recommended)
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// Step 1: Create a QueryClient instance
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Configure default staleTime for queries.
      // Data will be considered fresh for 5 seconds. After that,
      // if the component mounts or window refocuses, a background refetch will occur.
      staleTime: 1000 * 5, // 5 seconds
      // Configure default retry behavior
      retry: 3, // Retry failed queries 3 times
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30 * 1000), // Exponential backoff
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    {/* Step 2: Wrap your App with QueryClientProvider */}
    <QueryClientProvider client={queryClient}>
      <App />
      {/* Step 3: Add the Devtools component for easy debugging */}
      {/* initialIsOpen={false} keeps the devtools closed by default */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </React.StrictMode>,
);

Explanation:

  • We import QueryClient and QueryClientProvider from @tanstack/react-query.
  • We create a new QueryClient() instance. Here, we’ve also added defaultOptions for queries.
    • staleTime: 1000 * 5 means any data fetched will be considered “fresh” for 5 seconds. After that, it becomes “stale,” and TanStack Query will attempt a background refetch if the query is observed (e.g., component mounts, window regains focus).
    • retry: 3 tells TanStack Query to automatically retry failed network requests up to 3 times.
    • retryDelay implements an exponential backoff strategy, waiting longer between each retry attempt. This prevents overwhelming the server with immediate retries.
  • We wrap our entire <App /> component with <QueryClientProvider client={queryClient}>. This makes our queryClient instance available to all child components.
  • <ReactQueryDevtools initialIsOpen={false} /> adds a small floating button to your app that opens a powerful debugging panel. This is invaluable for seeing your cache, query states, and mutations.

3. Fetching Data with useQuery

Let’s create a TodoList component that fetches and displays our todos.

// src/App.tsx
import './App.css';
import TodoList from './components/TodoList';

function App() {
  return (
    <div className="App">
      <h1>My Awesome Todo App</h1>
      <TodoList />
    </div>
  );
}

export default App;

Now, create src/components/TodoList.tsx:

// src/components/TodoList.tsx
import { useQuery } from '@tanstack/react-query';
import React from 'react';

// Define the shape of a Todo item
interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

// Step 1: Define our asynchronous query function
// This function is responsible for making the actual API call.
const fetchTodos = async (): Promise<Todo[]> => {
  const response = await fetch('http://localhost:3001/todos');
  if (!response.ok) {
    throw new Error('Failed to fetch todos');
  }
  return response.json();
};

const TodoList: React.FC = () => {
  // Step 2: Use the useQuery hook
  // The first argument is the query key (['todos']), which uniquely identifies this query.
  // The second argument is our fetchTodos query function.
  const {
    data: todos,         // The fetched data (our list of todos)
    isLoading,         // true while the first fetch is in progress
    isError,           // true if the fetch failed
    error,             // The error object if isError is true
    isFetching,        // true during any fetch, including background refetches
    refetch,           // A function to manually refetch the data
  } = useQuery<Todo[], Error>({
    queryKey: ['todos'], // The unique key for this query
    queryFn: fetchTodos, // The function that fetches the data
  });

  // Step 3: Handle loading, error, and success states

  if (isLoading) {
    // isLoading is true only for the *initial* fetch.
    // We can also use isFetching if we want to show a spinner during background refetches.
    return <p>Loading todos...</p>;
  }

  if (isError) {
    return (
      <div>
        <p>Error: {error?.message}</p>
        <button onClick={() => refetch()}>Try Again</button> {/* Allow manual retry */}
      </div>
    );
  }

  if (!todos || todos.length === 0) {
    return <p>No todos found. Time to add some!</p>;
  }

  return (
    <div>
      <h2>Your Todos {isFetching ? <small>(Updating...)</small> : null}</h2>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.title}
            </span>
          </li>
        ))}
      </ul>
      <button onClick={() => refetch()}>Refresh Todos</button>
    </div>
  );
};

export default TodoList;

Explanation:

  • We define an interface Todo for type safety.
  • fetchTodos is an async function that makes the fetch API call. It’s crucial that this function returns a Promise and throws an error if the network request fails, as TanStack Query relies on this for error handling.
  • Inside TodoList, we call useQuery.
    • queryKey: ['todos']: This is our unique identifier. If any other component calls useQuery with ['todos'], TanStack Query will use the cached data and potentially refetch in the background.
    • queryFn: fetchTodos: This is the function that useQuery will execute to get the data.
  • We destructure the returned object to get todos (our data), isLoading, isError, error, and isFetching.
    • isLoading is true only for the first time the query runs.
    • isFetching is true for any fetch, including background refetches. This is useful for showing a subtle indicator that data is being updated.
  • We render different UI based on these states, providing a clear user experience for loading, error, and success.
  • The refetch function allows us to manually trigger a refetch of the data.

Start your React development server (npm run dev) and your JSON server (npm run server). You should see your todo list displayed, and if you open the React Query Devtools (the small icon at the bottom-left), you’ll see your ['todos'] query, its state, and cached data. Try going to another tab and coming back – you’ll notice a quick “Updating…” message as TanStack Query refetches in the background.

4. Adding Data with useMutation and Optimistic Updates

Now, let’s add a form to create new todos. We’ll use useMutation and implement optimistic updates, which are a fantastic way to improve UX by making the UI feel instantly responsive.

Create src/components/AddTodoForm.tsx:

// src/components/AddTodoForm.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useState } from 'react';

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

// Define our asynchronous mutation function for adding a todo
const addTodo = async (newTodo: Omit<Todo, 'id'>): Promise<Todo> => {
  const response = await fetch('http://localhost:3001/todos', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ ...newTodo, id: String(Date.now()) }), // json-server needs an ID
  });
  if (!response.ok) {
    throw new Error('Failed to add todo');
  }
  return response.json();
};

const AddTodoForm: React.FC = () => {
  const [title, setTitle] = useState('');
  // Step 1: Get the QueryClient instance from context
  const queryClient = useQueryClient();

  // Step 2: Use the useMutation hook
  const {
    mutate,          // The function to call to trigger the mutation
    isLoading,       // true while the mutation is in progress
    isError,         // true if the mutation failed
    error,           // The error object if isError is true
    isSuccess,       // true if the mutation succeeded
  } = useMutation<Todo, Error, Omit<Todo, 'id'>>({
    mutationFn: addTodo, // The function that performs the API call

    // Step 3: Implement Optimistic Updates
    onMutate: async (newTodo) => {
      // Cancel any outgoing refetches for the todos list
      // This ensures that our optimistic update isn't immediately overwritten
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // Snapshot the current todos list from the cache
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // Optimistically update the cache with the new todo
      // We assign a temporary ID for immediate UI display
      queryClient.setQueryData<Todo[]>(['todos'], (oldTodos) => {
        const tempId = `temp-${Date.now()}`; // Generate a temporary ID
        return oldTodos ? [...oldTodos, { ...newTodo, id: tempId, completed: false }] : [{ ...newTodo, id: tempId, completed: false }];
      });

      setTitle(''); // Clear the input field immediately

      // Return a context object with the snapshot, so we can roll back if needed
      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      // If the mutation fails, use the context to roll back the cache to its previous state
      console.error('Error adding todo:', err);
      if (context?.previousTodos) {
        queryClient.setQueryData<Todo[]>(['todos'], context.previousTodos);
      }
      // Optionally, show a toast notification to the user
      alert(`Failed to add todo: ${err.message}. Rolling back.`);
    },
    onSettled: () => {
      // After the mutation (either success or failure),
      // invalidate the 'todos' query to force a refetch.
      // This ensures our cache is eventually consistent with the server.
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
    onSuccess: () => {
      // Optionally, show a success message
      console.log('Todo added successfully!');
    }
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (title.trim()) {
      mutate({ title, completed: false }); // Call mutate to trigger the API request
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Add a new todo"
        disabled={isLoading} // Disable input while mutation is in progress
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Adding...' : 'Add Todo'}
      </button>
      {isError && <p style={{ color: 'red' }}>Error: {error?.message}</p>}
      {isSuccess && <p style={{ color: 'green' }}>Todo added!</p>}
    </form>
  );
};

export default AddTodoForm;

Update src/App.tsx to include the AddTodoForm:

// src/App.tsx
import './App.css';
import AddTodoForm from './components/AddTodoForm'; // Import the new component
import TodoList from './components/TodoList';

function App() {
  return (
    <div className="App">
      <h1>My Awesome Todo App</h1>
      <AddTodoForm /> {/* Add the form here */}
      <TodoList />
    </div>
  );
}

export default App;

Explanation:

  • addTodo is our mutation function. It performs a POST request.
  • useQueryClient() hook gives us access to the queryClient instance, which is essential for interacting with the cache directly.
  • useMutation is used for the addTodo operation. Notice the type parameters: <Todo, Error, Omit<Todo, 'id'>> represent data, error, and variables types respectively.
  • Optimistic Updates (onMutate, onError, onSettled):
    • onMutate: This callback runs before the mutation function (addTodo) is executed.
      • queryClient.cancelQueries({ queryKey: ['todos'] }): This is crucial! It tells TanStack Query to stop any ongoing background refetches for ['todos']. If we didn’t do this, a background refetch might complete after our optimistic update but before the server response, overwriting our optimistic state with potentially stale data.
      • queryClient.getQueryData<Todo[]>(['todos']): We capture the current state of the ['todos'] cache. This is our “snapshot” for potential rollback.
      • queryClient.setQueryData<Todo[]>(['todos'], (oldTodos) => { ... }): This is where the magic happens. We immediately update the cache with the new todo. We give it a temporary ID (temp-${Date.now()}) so React can render it with a unique key. The UI updates instantly!
      • We return { previousTodos } from onMutate. This context object will be passed to onError if the mutation fails.
    • onError: If addTodo fails (e.g., network error, server returns 500), this callback runs. We use the context.previousTodos to revert the cache to its state before the optimistic update, ensuring data consistency.
    • onSettled: This callback runs regardless of whether the mutation succeeded or failed. Its primary purpose here is to invalidateQueries({ queryKey: ['todos'] }). This tells TanStack Query that the ['todos'] data is now stale and needs to be refetched from the server. This ensures that:
      1. If the optimistic update succeeded, the temporary ID is replaced with the real server-generated ID.
      2. If the optimistic update failed and was rolled back, the list is still refetched to ensure it’s up-to-date.
    • onSuccess: (Optional) This runs only if the mutation succeeds.
  • The mutate function is called in handleSubmit with the new todo data.

Now, try adding a todo. You’ll notice it appears instantly in the list, and then a moment later, if you check the Devtools, you’ll see the ['todos'] query refetching to get the final server-confirmed state. To test the onError rollback, you could temporarily change the json-server URL in addTodo to a non-existent endpoint.

5. Updating and Deleting Todos

Let’s enhance our TodoList to allow toggling completion and deleting todos. This will involve more useMutation hooks and targeted query invalidation.

Modify src/components/TodoList.tsx:

// src/components/TodoList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import React from 'react';

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

const fetchTodos = async (): Promise<Todo[]> => {
  const response = await fetch('http://localhost:3001/todos');
  if (!response.ok) {
    throw new Error('Failed to fetch todos');
  }
  return response.json();
};

// Mutation function for updating a todo
const updateTodo = async (todo: Todo): Promise<Todo> => {
  const response = await fetch(`http://localhost:3001/todos/${todo.id}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(todo),
  });
  if (!response.ok) {
    throw new Error('Failed to update todo');
  }
  return response.json();
};

// Mutation function for deleting a todo
const deleteTodo = async (id: string): Promise<void> => {
  const response = await fetch(`http://localhost:3001/todos/${id}`, {
    method: 'DELETE',
  });
  if (!response.ok) {
    throw new Error('Failed to delete todo');
  }
};


const TodoList: React.FC = () => {
  const queryClient = useQueryClient(); // Get query client for mutations

  const {
    data: todos,
    isLoading,
    isError,
    error,
    isFetching,
    refetch,
  } = useQuery<Todo[], Error>({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  // Mutation for toggling todo completion
  const toggleTodoMutation = useMutation<Todo, Error, Todo>({
    mutationFn: updateTodo,
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // Optimistically update the specific todo in the cache
      queryClient.setQueryData<Todo[]>(['todos'], (oldTodos) =>
        oldTodos ? oldTodos.map((todo) => (todo.id === newTodo.id ? newTodo : todo)) : []
      );

      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      console.error('Error toggling todo:', err);
      if (context?.previousTodos) {
        queryClient.setQueryData<Todo[]>(['todos'], context.previousTodos);
      }
      alert(`Failed to toggle todo: ${err.message}. Rolling back.`);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  // Mutation for deleting a todo
  const deleteTodoMutation = useMutation<void, Error, string>({
    mutationFn: deleteTodo,
    onMutate: async (todoId) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // Optimistically remove the todo from the cache
      queryClient.setQueryData<Todo[]>(['todos'], (oldTodos) =>
        oldTodos ? oldTodos.filter((todo) => todo.id !== todoId) : []
      );

      return { previousTodos };
    },
    onError: (err, todoId, context) => {
      console.error('Error deleting todo:', err);
      if (context?.previousTodos) {
        queryClient.setQueryData<Todo[]>(['todos'], context.previousTodos);
      }
      alert(`Failed to delete todo: ${err.message}. Rolling back.`);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });


  if (isLoading) {
    return <p>Loading todos...</p>;
  }

  if (isError) {
    return (
      <div>
        <p>Error: {error?.message}</p>
        <button onClick={() => refetch()}>Try Again</button>
      </div>
    );
  }

  if (!todos || todos.length === 0) {
    return <p>No todos found. Time to add some!</p>;
  }

  return (
    <div>
      <h2>Your Todos {isFetching ? <small>(Updating...)</small> : null}</h2>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} style={{ display: 'flex', alignItems: 'center', marginBottom: '8px' }}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() =>
                toggleTodoMutation.mutate({ ...todo, completed: !todo.completed })
              }
              disabled={toggleTodoMutation.isLoading}
            />
            <span
              style={{
                textDecoration: todo.completed ? 'line-through' : 'none',
                marginRight: '10px',
                flexGrow: 1,
              }}
            >
              {todo.title}
            </span>
            <button
              onClick={() => deleteTodoMutation.mutate(todo.id)}
              disabled={deleteTodoMutation.isLoading}
              style={{ background: 'red', color: 'white', border: 'none', padding: '5px 10px', cursor: 'pointer' }}
            >
              {deleteTodoMutation.isLoading ? 'Deleting...' : 'Delete'}
            </button>
          </li>
        ))}
      </ul>
      <button onClick={() => refetch()}>Refresh Todos</button>
    </div>
  );
};

export default TodoList;

Explanation:

  • We’ve added updateTodo and deleteTodo asynchronous functions for our API calls.
  • Two new useMutation hooks: toggleTodoMutation and deleteTodoMutation.
  • Both mutations follow the same optimistic update pattern:
    1. onMutate: Cancel ongoing queries, snapshot previous cache, and optimistically update the specific item in the ['todos'] array in the cache.
    2. onError: Rollback to the previousTodos snapshot if the mutation fails.
    3. onSettled: Invalidate the ['todos'] query to ensure consistency after success or failure.
  • The UI now includes checkboxes to toggle completion and delete buttons. These buttons are disabled while their respective mutations are in progress (isLoading).

Now you have a fully functional Todo app that handles data fetching, caching, adding, updating, and deleting with robust error handling and optimistic updates, all powered by TanStack Query!

Mini-Challenge: Filtering Todos

You’ve built a solid foundation. Now, it’s your turn to enhance it!

Challenge: Implement a feature to filter the displayed todos by their completion status. Add buttons or a dropdown for “All”, “Active”, and “Completed” todos.

Hint:

  • How can you modify the queryKey to include a filter parameter? Remember, query keys are arrays, and they can contain objects! For example, ['todos', { status: 'active' }].
  • Your fetchTodos function will need to accept a status parameter and adjust the API URL accordingly (e.g., http://localhost:3001/todos?completed=false for active todos).
  • Remember that TanStack Query will cache each unique query key separately, which is exactly what we want here!

What to Observe/Learn:

  • How changing a query key triggers a new fetch and creates a new cache entry.
  • How TanStack Query handles multiple distinct queries based on their keys, allowing you to easily manage different views of the same data without manual caching logic.

Common Pitfalls & Troubleshooting

Even with a powerful library like TanStack Query, it’s easy to stumble. Here are some common pitfalls and how to debug them:

  1. Incorrect or Unstable Query Keys:

    • Pitfall: Using non-unique keys, or keys that change unnecessarily between renders (e.g., ['todos', { id: Math.random() }]). This leads to unnecessary refetches, incorrect caching, or queries not being found.
    • Troubleshooting: Always ensure your queryKey is stable and accurately represents the data you’re fetching. For parameterized queries, ['resource', { id: variableId, filter: someFilter }] is the correct pattern. Use the React Query Devtools to inspect active and inactive queries and their keys. If you see many duplicate queries with slightly different keys, you likely have an unstable key.
  2. Stale Data Issues (Misunderstanding staleTime and cacheTime):

    • Pitfall: Data seems to be re-fetching too often, or not often enough.
    • Troubleshooting:
      • Too frequent refetches: Your staleTime might be too low (default 0 means data is always stale). Increase staleTime for data that doesn’t change frequently.
      • Not refetching when expected: Ensure the component observing the query is mounted and the window is focused (default behavior). Manually call queryClient.invalidateQueries() after mutations to force refetches.
      • Use the React Query Devtools to see when a query last became stale and when it’s scheduled to refetch.
  3. Over-Invalidation:

    • Pitfall: Calling queryClient.invalidateQueries() too broadly (e.g., invalidateQueries({ queryKey: ['todos'] }) when only a specific todo changed, causing the entire list to refetch). This can lead to unnecessary network requests and performance issues.
    • Troubleshooting: Be as specific as possible with your invalidation keys. You can use partial matching: queryClient.invalidateQueries({ queryKey: ['todo', todoId] }) to invalidate a single item, or queryClient.invalidateQueries({ queryKey: ['todos'] }) for the list. For lists, invalidateQueries({ queryKey: ['todos'] }) is often acceptable after adding/deleting items.
  4. Race Conditions with Manual Cache Updates (without onMutate):

    • Pitfall: Trying to manually update the cache after a mutation succeeds, without using the onMutate rollback mechanism. If a background refetch completes before your manual update, or if your mutation fails, your cache can become inconsistent.
    • Troubleshooting: Always prefer onMutate for optimistic updates combined with onError for rollback and onSettled for final invalidation. This pattern is robust against network flakiness and race conditions.
  5. Debugging Tools:

    • React Query Devtools: This is your best friend. It shows all active/inactive queries, their data, state, mutation history, and cache configuration. Use it constantly!
    • Browser Network Tab: Observe the actual network requests. Are they happening when expected? Are retries occurring? Are requests being deduplicated?
    • console.log: Inside your queryFn and mutationFn, and within onMutate/onError/onSettled callbacks, to understand the flow of data and state.
    • queryClient.getQueryData(queryKey): You can programmatically inspect the cache’s current state in your component or even in your browser console if you expose queryClient globally (for debugging purposes only).

Summary

Congratulations! You’ve successfully navigated the complexities of server-side data fetching in React. You’ve learned:

  • The fundamental differences between client state and server state, and why a specialized library like TanStack Query is essential for managing server state effectively.
  • How to set up TanStack Query in your application using QueryClient and QueryClientProvider.
  • The critical role of queryKey in identifying and managing cached data.
  • How to fetch data using the useQuery hook, handling loading, error, and success states gracefully.
  • How TanStack Query implements intelligent caching and the “stale-while-revalidate” strategy for superior UX.
  • How to perform server-side mutations (add, update, delete) using the useMutation hook.
  • The power of optimistic updates with onMutate, onError, and onSettled to make your UI feel instantly responsive while maintaining data consistency.
  • Techniques for robust error handling, automatic retries with exponential backoff, and query invalidation.
  • Key debugging strategies and how to avoid common pitfalls.

By integrating TanStack Query into your workflow, you’ve significantly reduced boilerplate code, improved application performance, and built a more resilient and user-friendly experience. You’re now equipped to handle virtually any data-fetching scenario in a modern React application.

What’s Next?

In the next chapter, we’ll delve into the exciting world of authentication and authorization. How do you securely log users in? How do you protect routes and restrict UI elements based on user roles? How do you manage tokens and refresh flows? Stay tuned!

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.