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 useState or useReducer.
  • 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:

flowchart TD User[User Interaction] --> Component[UI Component] Component -->|Updates| ClientState[Client-Side State] ClientState -->|Renders| Component style ClientState fill:#f9f,stroke:#333,stroke-width:2px
  • 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:

flowchart TD User[User Interaction] --> Component[UI Component] Component -->|Triggers| TanStackQuery[TanStack Query] TanStackQuery -->|Fetches/Mutates| API[API Endpoint] API -->|Responds with Data| TanStackQuery TanStackQuery -->|Caches & Updates| Component style TanStackQuery fill:#bbf,stroke:#333,stroke-width:2px
  • 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. useState and 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 the queryClient available to any component nested inside it, allowing them to use useQuery, 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.ts file exports functions that return Promises, mimicking asynchronous API calls.
  • The delay function simulates network latency, making the asynchronous nature more apparent.
  • tasks array 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: This useState variable holds the text currently typed into the input box. It’s purely client-side.
  • isAddingTaskLocally: Another useState variable 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.fetchTasks is the function that actually performs the asynchronous data fetching. It must return a Promise.
  • Returned values: data, isLoading, isError, error, isFetching are all managed by TanStack Query, providing a consistent way to handle various states of your server data.
  • We use isLoading and isError to show appropriate messages to the user.
  • The tasks data is rendered in a list. Notice isFetching can show a subtle update indicator even if isLoading is 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 the queryClient instance we created in main.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 use queryClient.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 to api.addTask.
  • We use addTaskMutation.isPending to 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:

  1. You’ll need another useMutation for updateTask from our api.ts.
  2. The mutationFn will need to accept the taskId and the new completed status.
  3. Remember to invalidateQueries for ['tasks'] in the onSuccess callback of this new mutation to ensure the UI updates.
  4. You might want to pass an optimistic update to useMutation to 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

  1. Confusing Client-State with Server-State:

    • Pitfall: Trying to manage data fetched from an API using useState or 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 useState or similar.
  2. 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 invalidateQueries for the relevant queryKey in the onSuccess callback of your useMutation. This tells TanStack Query that the cached data is no longer valid and needs to be re-fetched.
  3. 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 queryKey definitions and enabling parallel queries. We’ll delve deeper into data loading strategies in future chapters.
  4. Troubleshooting with TanStack Query Devtools:

    • Tip: TanStack Query provides excellent Devtools. Install @tanstack/react-query-devtools@5 and add <ReactQueryDevtools initialIsOpen={false} /> to your App.tsx (inside QueryClientProvider). This tool is invaluable for inspecting query states, cache contents, and mutation statuses, making debugging much easier.

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 useState or 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.
  • useQuery is for fetching data (read operations), providing data, isLoading, isError, etc.
  • useMutation is for changing data on the server (write operations), and its onSuccess callback is vital for invalidateQueries to keep your UI in sync.
  • The queryKey is fundamental for TanStack Query to identify, cache, and manage your data.
  • Always use queryClient.invalidateQueries after 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

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