Learn the concept
Data Layer Architecture
Client state is data owned by the browser (UI state, form inputs, navigation) that doesn't need to be fetched. Server state is data owned by the backend (user profiles, posts, products) that's fetched over the network and cached locally. Using different tools for each (useState/Zustand for client, TanStack Query/SWR for server) prevents stale data bugs and eliminates manual cache management.
The distinction between client state and server state is one of the most important architectural concepts in modern frontend development. Treating all state the same way (as many Redux-heavy applications did) leads to complex, bug-prone code. Understanding the difference lets you choose the right tool for each type of state.
Client state is data that originates in and is owned by the browser. It has no source of truth on a server — it exists only in the user's current session.
Examples:
Characteristics of client state:
Tools for client state: useState, useReducer for component-local state; Zustand, Jotai, or Valtio for shared client state across components.
Server state is data that originates on a remote server. The frontend has a cached copy that may be stale, and multiple clients may be viewing/modifying the same data simultaneously.
Examples:
Characteristics of server state:
Tools for server state: TanStack Query (React Query), SWR, Apollo Client, RTK Query. These libraries handle the complexities of server state automatically.
The classic Redux pattern stored everything in one global store, including server data:
// The old Redux way — manual everything
const initialState = {
// Client state
isSidebarOpen: false,
activeTab: 'overview',
// Server state (manually managed)
users: [],
usersLoading: false,
usersError: null,
posts: [],
postsLoading: false,
postsError: null,
// ... repeat for every API resource
};This approach forces you to manually manage loading states, error states, caching, refetching, cache invalidation, race conditions, and deduplication for every single API resource. It's hundreds of lines of boilerplate that server state libraries handle automatically.
Worse, it leads to stale data bugs: you fetch user data, store it in Redux, navigate away, come back 10 minutes later, and the UI shows outdated information because nothing triggered a refetch.
Separate the concerns:
This separation eliminates boilerplate (no manual loading/error states), prevents stale data (automatic background refetching), and makes the codebase simpler to reason about.
Server state libraries implement the stale-while-revalidate (SWR) caching strategy:
This gives users the speed of cached data with the freshness of real-time data. The UI never shows a loading spinner for data it's seen before.
Client state is synchronous, browser-owned data (UI state, forms, navigation) — use useState or Zustand. Server state is asynchronous, server-owned data (fetched content, user profiles) — use TanStack Query or SWR. Mixing them in one store (the old Redux pattern) forces manual cache management and leads to stale data bugs. The modern approach separates them completely, letting specialized tools handle each type's unique challenges.
// BAD: Manual server state management with useState
// You handle loading, errors, caching, refetching yourself
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, [userId]);
// Problems:
// - No caching (refetches on every mount)
// - No background revalidation (stale data)
// - No deduplication (2 components = 2 requests)
// - Race conditions if userId changes quickly
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user.name}</div>;
}
// GOOD: Server state with TanStack Query
// Library handles caching, refetching, deduplication, race conditions
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId], // Cache key
queryFn: () => // How to fetch
fetch(`/api/users/${userId}`).then((res) => res.json()),
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
});
// Benefits:
// - Cached: instant display on revisit (no spinner)
// - Auto-revalidates in background after staleTime
// - Deduplicated: 10 components using same key = 1 request
// - Race conditions handled automatically
// - Shared across components via cache key
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user.name}</div>;
}Server state (posts, comments, likes) cached with TanStack Query and revalidated on focus; client state (which post's comment box is open, draft comment text) managed with useState — clean separation prevents stale likes and lost drafts
Cart items are server state (synced with backend for persistence across devices) managed with React Query mutations and optimistic updates; cart drawer open/closed is client state managed with useState
Dashboard data (metrics, charts) is server state fetched with query keys that include filter parameters; active filters, date range selection, and chart zoom level are client state managed with Zustand
Take a Redux-based todo app that manages everything in one store and refactor it to use Zustand for UI state (filter selection, edit mode) and TanStack Query for server state (todo items from API)
Build a dev tool overlay that displays the current TanStack Query cache (keys, stale status, last updated) alongside Zustand client state — visualizing the separation in real time
TanStack Query was created specifically to solve the server state problem — replacing hundreds of lines of Redux loading/error/caching boilerplate with declarative hooks that handle caching, deduplication, and background revalidation automatically
SWR (stale-while-revalidate) library was built by the Next.js team to pair with React's data fetching patterns — optimized for the stale-while-revalidate HTTP cache strategy
Migrated from Redux to Apollo Client for server state management and React local state for UI concerns, eliminating significant boilerplate and improving data caching across their booking flow