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,errorflags. - 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.
QueryClientandQueryClientProvider:- The
QueryClientis 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
QueryClientProvideris a React Context Provider that makes theQueryClientinstance available to all components within its tree. You wrap your root React component with it.
- The
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.
useQueryHook:- This is the primary hook for fetching data (typically
GETrequests). - 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, andrefetchproperties, among others.
- This is the primary hook for fetching data (typically
useMutationHook:- This hook is used for making changes to data on the server (typically
POST,PUT,DELETErequests). - It takes a mutation function that performs the server-side update.
- It returns an object with a
mutatefunction (which you call to trigger the mutation) and state variables likeisLoading,isError,error,isSuccess. - It also provides callback functions like
onSuccess,onError, andonSettledfor handling side effects after a mutation.
- This hook is used for making changes to data on the server (typically
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 is0(data is immediately stale).cacheTime: How long inactive (unused) query data remains in the cache before it’s garbage collected. Default is5 minutes.
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
QueryClientandQueryClientProviderfrom@tanstack/react-query. - We create a
new QueryClient()instance. Here, we’ve also addeddefaultOptionsforqueries.staleTime: 1000 * 5means 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: 3tells TanStack Query to automatically retry failed network requests up to 3 times.retryDelayimplements 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 ourqueryClientinstance 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 Todofor type safety. fetchTodosis anasyncfunction that makes thefetchAPI call. It’s crucial that this function returns aPromiseand throws an error if the network request fails, as TanStack Query relies on this for error handling.- Inside
TodoList, we calluseQuery.queryKey: ['todos']: This is our unique identifier. If any other component callsuseQuerywith['todos'], TanStack Query will use the cached data and potentially refetch in the background.queryFn: fetchTodos: This is the function thatuseQuerywill execute to get the data.
- We destructure the returned object to get
todos(our data),isLoading,isError,error, andisFetching.isLoadingistrueonly for the first time the query runs.isFetchingistruefor 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
refetchfunction 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:
addTodois our mutation function. It performs aPOSTrequest.useQueryClient()hook gives us access to thequeryClientinstance, which is essential for interacting with the cache directly.useMutationis used for theaddTodooperation. Notice the type parameters:<Todo, Error, Omit<Todo, 'id'>>representdata,error, andvariablestypes 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 }fromonMutate. This context object will be passed toonErrorif the mutation fails.
onError: IfaddTodofails (e.g., network error, server returns 500), this callback runs. We use thecontext.previousTodosto 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 toinvalidateQueries({ queryKey: ['todos'] }). This tells TanStack Query that the['todos']data is now stale and needs to be refetched from the server. This ensures that:- If the optimistic update succeeded, the temporary ID is replaced with the real server-generated ID.
- 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
mutatefunction is called inhandleSubmitwith 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
updateTodoanddeleteTodoasynchronous functions for our API calls. - Two new
useMutationhooks:toggleTodoMutationanddeleteTodoMutation. - Both mutations follow the same optimistic update pattern:
onMutate: Cancel ongoing queries, snapshot previous cache, and optimistically update the specific item in the['todos']array in the cache.onError: Rollback to thepreviousTodossnapshot if the mutation fails.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
queryKeyto include a filter parameter? Remember, query keys are arrays, and they can contain objects! For example,['todos', { status: 'active' }]. - Your
fetchTodosfunction will need to accept astatusparameter and adjust the API URL accordingly (e.g.,http://localhost:3001/todos?completed=falsefor 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:
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
queryKeyis 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.
- Pitfall: Using non-unique keys, or keys that change unnecessarily between renders (e.g.,
Stale Data Issues (Misunderstanding
staleTimeandcacheTime):- Pitfall: Data seems to be re-fetching too often, or not often enough.
- Troubleshooting:
- Too frequent refetches: Your
staleTimemight be too low (default 0 means data is always stale). IncreasestaleTimefor 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
staleand when it’s scheduled to refetch.
- Too frequent refetches: Your
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, orqueryClient.invalidateQueries({ queryKey: ['todos'] })for the list. For lists,invalidateQueries({ queryKey: ['todos'] })is often acceptable after adding/deleting items.
- Pitfall: Calling
Race Conditions with Manual Cache Updates (without
onMutate):- Pitfall: Trying to manually update the cache after a mutation succeeds, without using the
onMutaterollback mechanism. If a background refetch completes before your manual update, or if your mutation fails, your cache can become inconsistent. - Troubleshooting: Always prefer
onMutatefor optimistic updates combined withonErrorfor rollback andonSettledfor final invalidation. This pattern is robust against network flakiness and race conditions.
- Pitfall: Trying to manually update the cache after a mutation succeeds, without using the
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 yourqueryFnandmutationFn, and withinonMutate/onError/onSettledcallbacks, 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 exposequeryClientglobally (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
QueryClientandQueryClientProvider. - The critical role of
queryKeyin identifying and managing cached data. - How to fetch data using the
useQueryhook, 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
useMutationhook. - The power of optimistic updates with
onMutate,onError, andonSettledto 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
- TanStack Query Official Documentation
- React Official Documentation
- MDN Web Docs - Using the Fetch API
- json-server GitHub Repository
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.