DEV Community

Cover image for Server State vs Client State in React: Why TanStack Query Changes Everything
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

Server State vs Client State in React: Why TanStack Query Changes Everything

Server State vs Client State in React: Why TanStack Query Changes Everything

title: "Server State vs Client State in React: Why TanStack Query Changes Everything"
published: true
published_at: "2026-11-18T09:00:00+05:30"
description: "Explore the architectural differences between server state and client state in React. Learn how TanStack Query eliminates Redux boilerplate for API calls through automated caching, background refetching, and optimistic updates."
tags: [react, webdev, frontend, javascript]
ai_disclosure_level: some_ai

Introduction

For years, front-end developers treated remote data fetched from a backend API the same way they treated local application state, such as form inputs, modal toggles, or theme settings. This architectural confusion led to the rise of massive Redux stores designed to hold normalized API responses, accompanied by endless boilerplate: action types, action creators, reducers, and asynchronous middleware like redux-thunk or redux-saga.

However, remote data is fundamentally different from local state. Remote data lives on a server outside your control, can be updated by other clients, and requires asynchronous network operations that can fail, timeout, or become stale.

This article examines the architectural shift required to separate Server State from Client State, and how libraries like TanStack Query (formerly React Query) eliminate boilerplate while dramatically improving application performance and user experience.

Understanding the Divide: Server State vs. Client State

To build resilient React applications, you must recognize the distinct characteristics of these two state domains:

Feature Client State Server State
Ownership Explicitly owned by the client. Owned remotely; the client only holds a snapshot.
Persistence Session-based, volatile or stored locally (localStorage). Persistent in a database.
Latency Instantaneous (synchronous memory access). Asynchronous over a network with variable latency.
Staleness Never stale unless the app's logic explicitly invalidates it. Always at risk of becoming stale the moment it is fetched.
Sharing Typically local to a component or subtree. Shared globally across multiple users and components.

When you force server data into a global client state store like Redux or Zustand, you take on the burden of manually managing loading states, error handling, race conditions, deduplication, and cache invalidation.

The Traditional Redux Approach (The Anti-Pattern)

Consider what it takes to fetch a list of user profiles using traditional Redux:

// 1. Action Types
const FETCH_USERS_REQUEST = 'FETCH_USERS_REQUEST';
const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';

// 2. Action Creators
const fetchUsersRequest = () => ({ type: FETCH_USERS_REQUEST });
const fetchUsersSuccess = (users) => ({ type: FETCH_USERS_SUCCESS, payload: users });
const fetchUsersFailure = (error) => ({ type: FETCH_USERS_FAILURE, payload: error });

// 3. Thunk Action
export const fetchUsers = () => async (dispatch) => {
  dispatch(fetchUsersRequest());
  try {
    const response = await api.get('/users');
    dispatch(fetchUsersSuccess(response.data));
  } catch (error) {
    dispatch(fetchUsersFailure(error.message));
  }
};

// 4. Reducer
const initialState = { loading: false, users: [], error: null };
export default function usersReducer(state = initialState, action) {
  switch (action.type) {
    case FETCH_USERS_REQUEST:
      return { ...state, loading: true, error: null };
    case FETCH_USERS_SUCCESS:
      return { ...state, loading: false, users: action.payload };
    case FETCH_USERS_FAILURE:
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is more than 30 lines of code just to perform a basic GET request, and it completely ignores caching, stale-while-revalidate logic, window focus refetching, and garbage collection.

Enter TanStack Query: Declarative Data Fetching

TanStack Query treats remote data as an asynchronous hook-driven query. Here is the exact same data-fetching requirement implemented with TanStack Query:

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

const fetchUsers = async () => {
  const { data } = await axios.get('/api/users');
  return data;
};

export function UserList() {
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isLoading) return <div>Loading users...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

By replacing imperative action dispatching with declarative hooks, you instantly eliminate your API reducers, thunks, and action constants.

Core Capabilities That Change Everything

1. Automatic Cache Invalidation and Stale-While-Revalidate

TanStack Query uses a smart caching strategy. Data is categorized as stale based on a configurable staleTime. When a component mounts or window focus occurs, if the data is stale, the library returns the cached data immediately while firing a background refetch to ensure the UI updates with fresh server data.

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUserById(userId),
  staleTime: 1000 * 60 * 5, // Data remains fresh for 5 minutes
});
Enter fullscreen mode Exit fullscreen mode

2. Eliminating Race Conditions and Request Deduplication

If three components on the same page mount simultaneously and call useQuery({ queryKey: ['currentUser'] }), TanStack Query intelligently deduplicates the requests. It sends a single network request and shares the resulting promise among all three components.

3. Mutations and Optimistic Updates

Handling mutations (POST, PUT, DELETE) traditionally required rolling back manual state updates upon failure. TanStack Query simplifies mutations via useMutation and provides robust tools for Optimistic Updates—updating the UI instantly before the server responds.

import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (updatedUser) => axios.put(`/api/users/${updatedUser.id}`, updatedUser),
    // When mutation is triggered:
    onMutate: async (updatedUser) => {
      await queryClient.cancelQueries({ queryKey: ['user', updatedUser.id] });

      // Snapshot the previous value
      const previousUser = queryClient.getQueryData(['user', updatedUser.id]);

      // Optimistically update to the new value
      queryClient.setQueryData(['user', updatedUser.id], updatedUser);

      // Return context with the snapshotted value
      return { previousUser };
    },
    // If the mutation fails, use the context returned from onMutate to roll back
    onError: (err, updatedUser, context) => {
      queryClient.setQueryData(['user', updatedUser.id], context.previousUser);
    },
    // Always refetch after error or success:
    onSettled: (updatedUser) => {
      queryClient.invalidateQueries({ queryKey: ['user', updatedUser.id] });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

4. Seamless Pagination and Infinite Scrolling

Managing page offsets or cursor-based infinite scrolls in Redux requires complex normalization logic. TanStack Query provides specialized hooks like useInfiniteQuery that manage page parameters automatically.

import { useInfiniteQuery } from '@tanstack/react-query';

const fetchProjects = async ({ pageParam = 0 }) => {
  const res = await axios.get(`/api/projects?cursor=${pageParam}`);
  return res.data;
};

export function ProjectsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  return (
    <div>
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.projects.map((project) => (
            <div key={project.id}>{project.name}</div>
          ))}
        </React.Fragment>
      ))}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load More' : 'Nothing more to load'}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Separating server state from client state is one of the most impactful architectural decisions you can make in a modern React application. By delegating data caching, synchronization, garbage collection, and background refetching to TanStack Query, you reduce your client bundle size, eliminate hundreds of lines of boilerplate state-management code, and deliver a snappy, resilient user experience.

Top comments (0)