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;
}
}
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>
);
}
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
});
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] });
},
});
}
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>
);
}
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)