Introduction
Welcome to Chapter 11! In the world of modern web development, managing data is a central challenge. As applications grow in complexity, distinguishing between different types of data, and how we handle them, becomes critical for performance, maintainability, and user experience. This chapter dives deep into a fundamental concept: the difference between Server-State and Client-State.
Understanding this distinction is not just academic; it’s a cornerstone for building robust applications, especially when leveraging powerful tools from the TanStack ecosystem like TanStack Query. We’ll explore why some data lives on your server, why other data lives purely in your browser’s memory, and how TanStack Query provides an elegant solution for the former, complementing your existing client-side state management.
By the end of this chapter, you’ll have a clear mental model for classifying data in your applications, understand the unique challenges each type presents, and see how TanStack Query (v5 as of January 2026) is purpose-built to tackle server-state complexities. We’ll build on your understanding of basic React (or your preferred framework) and asynchronous operations.
Core Concepts: Understanding State Types
Let’s start by dissecting the two primary categories of state you’ll encounter in almost any web application.
What is Client-State?
Imagine the current state of your user interface – what tab is active, if a modal is open, the text a user has typed into an input field before they’ve submitted it, or perhaps a theme preference. This is Client-State.
Client-state is:
- Ephemeral: It often exists only for the current user session and might be lost if the page refreshes (unless explicitly persisted to local storage).
- User-specific: It pertains to the individual user’s interaction with the UI.
- Synchronous & Predictable: Changes to client-state are usually immediate and directly controlled by your application’s logic.
- Managed on the client: It resides entirely within the browser’s memory.
Common ways to manage client-state include:
- Framework-specific hooks like React’s
useStateoruseReducer. - Global state management libraries like Zustand, Redux, Jotai, or even TanStack Store for more complex shared client-state.
Client-State Flow Diagram
Let’s visualize how client-state typically operates within your application:
- User Interaction: The user performs an action (e.g., clicks a button, types in an input).
- UI Component: Your component receives this interaction.
- Updates Client-Side State: The component updates its internal client-side state (e.g.,
useState). - Renders Component: The UI re-renders based on the new client-state. Simple, right?
What is Server-State?
Now, consider data that is stored remotely, typically in a database, accessed via an API, and potentially shared across multiple users or sessions. This is Server-State. Think of a list of products in an e-commerce store, a user’s profile information, or a collection of blog posts.
Server-state is characterized by:
- Persistent: It lives beyond a single user session, stored on a server.
- Shared: Often accessible and modifiable by multiple users.
- Asynchronous: Fetching and updating server-state always involves network requests, making it inherently asynchronous.
- Unpredictable: You don’t always know if the data on the server has changed since you last fetched it.
- Needs Synchronization: Keeping your client-side UI in sync with the server’s truth is a major challenge.
Managing server-state comes with a unique set of challenges:
- Fetching: Making HTTP requests.
- Caching: Storing fetched data locally to avoid unnecessary requests.
- Invalidation: Knowing when cached data is stale and needs to be re-fetched.
- Synchronization: Ensuring the UI reflects the latest server data.
- Updates: Sending changes back to the server.
- Loading & Error States: Handling the various stages of an asynchronous request.
- Retries & Deduping: Optimizing network requests.
This is exactly where TanStack Query (v5) shines. It’s often described as the “missing data-fetching library” because it elegantly handles all these complexities for you, allowing you to focus on your UI.
Server-State Flow Diagram (with TanStack Query)
Here’s how managing server-state looks, especially with a library like TanStack Query:
- User Interaction: The user wants to see or change server data.
- UI Component: The component initiates a data operation (fetch or mutate).
- TanStack Query: Intercepts this, manages the request, caching, and state.
- API Endpoint: The actual network request goes to your backend API.
- Responds with Data: The API sends back the data.
- Caches & Updates: TanStack Query caches this data, notifies components, and the UI re-renders.
The Crucial Distinction: Why it Matters
The key takeaway is that server-state and client-state are fundamentally different and require different management strategies.
- Client-State is your application’s personal scratchpad. It’s fast, synchronous, and directly reflects the user’s immediate interactions.
useStateand similar tools are perfect here. - Server-State is the shared, authoritative truth. It’s slow (network requests!), asynchronous, and needs careful synchronization. TanStack Query is your robust manager for this.
TanStack Query does NOT replace your client-state manager. Instead, it specializes in server-state, freeing your client-state tools to manage only truly client-side concerns. This separation of concerns leads to:
- Cleaner Code: Your components become simpler, focusing on rendering rather than data-fetching logic.
- Improved Performance: Intelligent caching and background re-fetching make your app feel snappier.
- Better Developer Experience: Less boilerplate, automatic re-fetching, and built-in loading/error states.
- Robustness: Handling network errors, retries, and race conditions becomes trivial.
Step-by-Step Implementation: Tasks Application
Let’s illustrate this distinction with a simple React application that manages a list of tasks. We’ll use client-state for a new task input field and server-state for the actual task list.
We’ll assume you have a basic React (with Vite or Create React App) project set up.
Step 1: Install TanStack Query
First, let’s add TanStack Query to our project. As of January 2026, TanStack Query v5 is the stable release.
Open your terminal in your project’s root and run:
npm install @tanstack/react-query@5
# or
yarn add @tanstack/react-query@5
Step 2: Set up the QueryClient and Provider
TanStack Query needs a QueryClient instance to manage its cache and a QueryClientProvider to make that instance available throughout your React component tree.
Open your src/main.tsx (or src/index.tsx) file and modify it:
// src/main.tsx (or src/index.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';
// 1. Create a client
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* 2. Provide the client to your App */}
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
);
QueryClient: This is the core instance that manages all your query caching and state. We create it once.QueryClientProvider: This context provider makes thequeryClientavailable to any component nested inside it, allowing them to useuseQuery,useMutation, etc.
Step 3: Create a “Fake” API Service
To simulate fetching data from a backend, let’s create a simple API service that returns promises. This will act as our “server.”
Create a new file src/api.ts:
// src/api.ts
// Simulate a database
interface Task {
id: string;
text: string;
completed: boolean;
}
let tasks: Task[] = [
{ id: '1', text: 'Learn TanStack Query', completed: false },
{ id: '2', text: 'Understand Server-State', completed: true },
{ id: '3', text: 'Build a small app', completed: false },
];
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const api = {
// Fetches all tasks
fetchTasks: async (): Promise<Task[]> => {
await delay(500); // Simulate network latency
console.log('API: Fetched tasks');
return tasks;
},
// Adds a new task
addTask: async (text: string): Promise<Task> => {
await delay(700); // Simulate network latency
const newTask: Task = {
id: String(tasks.length + 1), // Simple ID generation
text,
completed: false,
};
tasks.push(newTask);
console.log('API: Added task', newTask);
return newTask;
},
// Updates a task (e.g., mark as complete)
updateTask: async (id: string, updates: Partial<Task>): Promise<Task> => {
await delay(600);
tasks = tasks.map((task) =>
task.id === id ? { ...task, ...updates } : task,
);
const updatedTask = tasks.find((task) => task.id === id);
console.log('API: Updated task', updatedTask);
if (!updatedTask) {
throw new Error(`Task with id ${id} not found`);
}
return updatedTask;
},
};
- This
api.tsfile exports functions that return Promises, mimicking asynchronous API calls. - The
delayfunction simulates network latency, making the asynchronous nature more apparent. tasksarray acts as our in-memory “database” on the server.
Step 4: Implement Client-State for New Task Input
Now, let’s work on our App.tsx component. We’ll start with the client-state for the input field where users type new tasks.
Open src/App.tsx:
// src/App.tsx
import React, { useState } from 'react';
import './App.css'; // Assuming some basic styling
function App() {
// Client-state for the new task input field
const [newTaskText, setNewTaskText] = useState('');
// Client-state for local loading indicator (before server interaction)
const [isAddingTaskLocally, setIsAddingTaskLocally] = useState(false);
const handleNewTaskTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewTaskText(e.target.value);
};
const handleAddTask = async () => {
if (!newTaskText.trim()) return;
setIsAddingTaskLocally(true); // Set local loading state
console.log('Client-State: Preparing to add task:', newTaskText);
// In a real app, this would trigger a server-state mutation
// For now, we're just showing the client-state.
await new Promise(resolve => setTimeout(resolve, 300)); // Simulate some local processing
console.log('Client-State: Task input processed, ready for server.');
setNewTaskText(''); // Clear input after "submission"
setIsAddingTaskLocally(false); // Reset local loading state
};
return (
<div className="app-container">
<h1>Task List</h1>
<div className="new-task-form">
<input
type="text"
value={newTaskText}
onChange={handleNewTaskTextChange}
placeholder="What needs to be done?"
disabled={isAddingTaskLocally}
/>
<button onClick={handleAddTask} disabled={isAddingTaskLocally}>
{isAddingTaskLocally ? 'Adding...' : 'Add Task'}
</button>
</div>
<hr />
<h2>Tasks (Server-State will go here)</h2>
{/* This is where our server-state tasks will be displayed */}
<p>Loading tasks...</p>
</div>
);
}
export default App;
newTaskText: ThisuseStatevariable holds the text currently typed into the input box. It’s purely client-side.isAddingTaskLocally: AnotheruseStatevariable for a local loading indicator. This shows how you might manage UI states that are only relevant to the immediate user interaction.handleAddTask: For now, this function just simulates a local process and clears the input, demonstrating client-state management. The actual server interaction comes next!
Step 5: Implement Server-State for Fetching Tasks with useQuery
Now, let’s use TanStack Query to fetch and display our tasks, which are server-state.
Modify src/App.tsx again:
// src/App.tsx
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; // Import useQuery
import { api } from './api'; // Import our fake API
import './App.css';
function App() {
const [newTaskText, setNewTaskText] = useState('');
// 1. Use useQuery to fetch tasks (Server-State)
const {
data: tasks, // The fetched data (our task list)
isLoading, // True while the data is being fetched for the first time
isError, // True if the query failed
error, // The error object if isError is true
isFetching, // True for any background fetch (initial, refetch, etc.)
} = useQuery({
queryKey: ['tasks'], // A unique key for this query
queryFn: api.fetchTasks, // The function that fetches the data
});
const handleNewTaskTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewTaskText(e.target.value);
};
const handleAddTask = async () => {
if (!newTaskText.trim()) return;
// We will integrate server-state mutation here in the next step
console.log('Client-State: Preparing to add task:', newTaskText);
await new Promise(resolve => setTimeout(resolve, 300));
console.log('Client-State: Task input processed, ready for server.');
setNewTaskText('');
};
if (isLoading) return <div className="app-container"><h2>Loading tasks...</h2></div>;
if (isError) return <div className="app-container"><h2>Error: {error?.message}</h2></div>;
return (
<div className="app-container">
<h1>Task List</h1>
<div className="new-task-form">
<input
type="text"
value={newTaskText}
onChange={handleNewTaskTextChange}
placeholder="What needs to be done?"
/>
<button onClick={handleAddTask}>
Add Task
</button>
</div>
<hr />
<h2>Tasks (Server-State) {isFetching ? ' (Updating...)' : ''}</h2>
{tasks && tasks.length > 0 ? (
<ul>
{tasks.map((task) => (
<li key={task.id} style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
{task.text}
</li>
))}
</ul>
) : (
<p>No tasks yet. Add one!</p>
)}
</div>
);
}
export default App;
useQuery({ queryKey: ['tasks'], queryFn: api.fetchTasks }): This is the core of fetching server-state.queryKey:['tasks']is a unique identifier. TanStack Query uses this key for caching, refetching, and sharing data.queryFn:api.fetchTasksis the function that actually performs the asynchronous data fetching. It must return a Promise.
- Returned values:
data,isLoading,isError,error,isFetchingare all managed by TanStack Query, providing a consistent way to handle various states of your server data. - We use
isLoadingandisErrorto show appropriate messages to the user. - The
tasksdata is rendered in a list. NoticeisFetchingcan show a subtle update indicator even ifisLoadingis false (meaning data is already present but being refreshed in the background).
Run your application now (npm run dev or yarn dev). You should see the initial tasks loaded after a brief delay. Try refreshing the page – TanStack Query’s cache will kick in, making subsequent loads faster!
Step 6: Implement Server-State for Adding a Task with useMutation
Now, let’s connect our Add Task button to actually add tasks to our “server” using TanStack Query’s useMutation hook. This is how we interact with server-state to change it.
Modify src/App.tsx one last time:
// src/App.tsx
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; // Import useMutation and useQueryClient
import { api } from './api';
import './App.css';
function App() {
const [newTaskText, setNewTaskText] = useState('');
const queryClient = useQueryClient(); // Get the query client instance
const {
data: tasks,
isLoading,
isError,
error,
isFetching,
} = useQuery({
queryKey: ['tasks'],
queryFn: api.fetchTasks,
});
// 1. Use useMutation for adding a task (Server-State modification)
const addTaskMutation = useMutation({
mutationFn: api.addTask, // The function that performs the async modification
onSuccess: () => {
// Invalidate the 'tasks' query to trigger a refetch
// This ensures our UI reflects the latest server-state
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setNewTaskText(''); // Clear client-state input on success
},
onError: (mutationError) => {
console.error("Failed to add task:", mutationError);
alert(`Error adding task: ${mutationError.message}`);
},
});
const handleNewTaskTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewTaskText(e.target.value);
};
const handleAddTask = () => {
if (!newTaskText.trim()) return;
// Trigger the mutation
addTaskMutation.mutate(newTaskText); // Pass the task text to the mutationFn
};
if (isLoading) return <div className="app-container"><h2>Loading tasks...</h2></div>;
if (isError) return <div className="app-container"><h2>Error: {error?.message}</h2></div>;
return (
<div className="app-container">
<h1>Task List</h1>
<div className="new-task-form">
<input
type="text"
value={newTaskText}
onChange={handleNewTaskTextChange}
placeholder="What needs to be done?"
disabled={addTaskMutation.isPending} {/* Disable while mutation is in progress */}
/>
<button
onClick={handleAddTask}
disabled={addTaskMutation.isPending || !newTaskText.trim()}
>
{addTaskMutation.isPending ? 'Adding...' : 'Add Task'}
</button>
</div>
<hr />
<h2>Tasks (Server-State) {isFetching ? ' (Updating...)' : ''}</h2>
{tasks && tasks.length > 0 ? (
<ul>
{tasks.map((task) => (
<li key={task.id} style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
{task.text}
</li>
))}
</ul>
) : (
<p>No tasks yet. Add one!</p>
)}
</div>
);
}
export default App;
useQueryClient(): This hook gives us access to thequeryClientinstance we created inmain.tsx. We need it to interact with the cache.useMutation({ mutationFn: api.addTask, onSuccess: () => { ... } }):mutationFn: This is the asynchronous function that sends data to the server to make a change.onSuccess: This callback runs when the mutation successfully completes. Here, we usequeryClient.invalidateQueries({ queryKey: ['tasks'] }). This is crucial! It tells TanStack Query that the data for the['tasks']key is now stale and needs to be re-fetched in the background. This automatically updates our UI with the newly added task without us manually managing state.onError: Handles errors during the mutation.
addTaskMutation.mutate(newTaskText): This function is called to trigger the mutation, passing the new task’s text as its argument toapi.addTask.- We use
addTaskMutation.isPendingto disable the input and button while the task is being added, providing a good user experience.
Now, refresh your application and try adding new tasks. You’ll see the “Adding…” state, a brief delay, and then the new task magically appears in the list as TanStack Query automatically refetches the updated server-state! This seamless update is the power of separating concerns and letting TanStack Query manage your server-state.
Mini-Challenge: Mark Task as Complete
You’ve successfully fetched tasks and added new ones. Now, for a small challenge:
Challenge: Add a “Mark as Complete” button next to each task. When clicked, this button should toggle the completed status of that specific task on the server.
Hint:
- You’ll need another
useMutationforupdateTaskfrom ourapi.ts. - The
mutationFnwill need to accept thetaskIdand the newcompletedstatus. - Remember to
invalidateQueriesfor['tasks']in theonSuccesscallback of this new mutation to ensure the UI updates. - You might want to pass an
optimistic updatetouseMutationto make the UI feel even faster, but for this challenge, just a simple invalidation is sufficient.
What to Observe/Learn: Pay attention to how the invalidateQueries mechanism makes updating the UI after a server change incredibly straightforward and consistent across different types of mutations.
Common Pitfalls & Troubleshooting
Confusing Client-State with Server-State:
- Pitfall: Trying to manage data fetched from an API using
useStateor a simple global client-state manager. This leads to manual caching, complex refetching logic, and difficult error handling. - Solution: If data comes from an external source (API, database), it’s server-state. Use TanStack Query. If it’s purely UI-driven and ephemeral, it’s client-state; use
useStateor similar.
- Pitfall: Trying to manage data fetched from an API using
Forgetting to Invalidate Queries After Mutations:
- Pitfall: You perform a
useMutation(e.g., add, update, delete an item), but your UI doesn’t update, or it shows stale data. - Solution: Always
invalidateQueriesfor the relevantqueryKeyin theonSuccesscallback of youruseMutation. This tells TanStack Query that the cached data is no longer valid and needs to be re-fetched.
- Pitfall: You perform a
Over-fetching/Under-fetching:
- Pitfall: Fetching too much data (e.g., an entire database table when you only need a few columns) or not fetching enough (leading to multiple requests for related data).
- Solution: This is more about API design, but TanStack Query can help by allowing fine-grained
queryKeydefinitions and enabling parallel queries. We’ll delve deeper into data loading strategies in future chapters.
Troubleshooting with TanStack Query Devtools:
- Tip: TanStack Query provides excellent Devtools. Install
@tanstack/react-query-devtools@5and add<ReactQueryDevtools initialIsOpen={false} />to yourApp.tsx(insideQueryClientProvider). This tool is invaluable for inspecting query states, cache contents, and mutation statuses, making debugging much easier.
- Tip: TanStack Query provides excellent Devtools. Install
Summary
Congratulations! You’ve successfully navigated the crucial distinction between client-state and server-state.
Here are the key takeaways from this chapter:
- Client-State is ephemeral, user-specific UI data managed synchronously within the browser, typically with
useStateor dedicated client-state libraries. - Server-State is persistent, shared, asynchronous data stored remotely, which introduces challenges like fetching, caching, and synchronization.
- TanStack Query (v5) is a powerful library specifically designed to manage server-state, handling complexities like caching, invalidation, background refetching, and error handling automatically.
- TanStack Query complements, rather than replaces, your client-state management solutions.
useQueryis for fetching data (read operations), providingdata,isLoading,isError, etc.useMutationis for changing data on the server (write operations), and itsonSuccesscallback is vital forinvalidateQueriesto keep your UI in sync.- The
queryKeyis fundamental for TanStack Query to identify, cache, and manage your data. - Always use
queryClient.invalidateQueriesafter a successful mutation to ensure your UI reflects the latest server-side truth.
Now that you have a solid grasp of state separation, you’re well-equipped to leverage TanStack Query’s full potential. In the next chapter, we’ll dive deeper into the core concepts of TanStack Query, exploring more advanced caching strategies, query keys, and data transformation techniques.
References
- TanStack Query Official Documentation (v5)
- Does TanStack Query replace client state?
- TanStack Query Devtools
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.