Server State vs Client State — React Query / TanStack Query
Day 9 of 40 — React System Design Series

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.
Hey everyone, Richa here! 👋

Day 9. And today I want to talk about the distinction that changed how I think about state management completely.
Server state vs client state.
For years I put everything in the same bucket. User data from an API? Put it in Redux. UI theme? Put it in Redux. Cart items? Redux. Whether the sidebar is open? Also Redux.
It all lived in one global store and I managed all of it the same way.
Then someone on my team said — "that API data isn't your state. It's a cache. The server owns it. You're just borrowing it."
That one sentence broke my brain a little. And then it all made sense.

The two types of state — and why they're completely different
Client state is state that your app owns. It lives in the browser. The server doesn't know about it and doesn't care.
// Client state examples
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [selectedTheme, setSelectedTheme] = useState('dark');
const [currentStep, setCurrentStep] = useState(1);
These values exist only in your app. They start fresh on every page load. Nobody else needs to know about them. Managing them with useState, useReducer, Zustand, or Context makes total sense.
Server state is data that lives on a server and you're temporarily displaying in your UI.
// Server state examples — you don't own these
const users = await fetch('/api/users'); // the server owns this
const product = await fetch('/api/products/1'); // the server owns this
const orders = await fetch('/api/orders'); // the server owns this
The server owns this data. It can change at any time — another user edits something, a background job updates a record, someone else places an order. Your local copy might be stale the moment you receive it.
This is why server state has problems that client state doesn't:
- Caching — you fetched it once, you don't want to fetch it again on every render
- Revalidation — how do you know when your cached copy is stale?
- Background refetching — should you quietly refresh when the user comes back to the tab?
- Loading and error states — every fetch has these, and you write them from scratch every time
- Deduplication — if two components mount at the same time and both need
/api/users, do you make two requests? - Pagination and infinite scroll — complex async patterns that are painful to build manually
If you've been managing server state with Redux or Zustand — you've been building a half-baked version of React Query by hand.
What React Query (TanStack Query) actually is
React Query is a server state management library. It handles all of the above — caching, revalidation, background refetching, loading states, error states, deduplication — with almost no code from you.
Install it:
npm install @tanstack/react-query
Set it up (one time, at the root):
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
Now use it anywhere:
import { useQuery } from '@tanstack/react-query';
function UsersPage() {
const { data: users, isLoading, isError, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
});
if (isLoading) return <Spinner />;
if (isError) return <ErrorMessage message={error.message} />;
return (
<ul>
{users.map((user: User) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
That's it. No useState for loading. No useState for error. No useEffect for the fetch. No AbortController. React Query handles all of it.
And the data is cached. If another component on the same page also calls useQuery({ queryKey: ['users'] }) — it gets the same cached data instantly. Zero extra network requests.
The queryKey — the most important concept
The queryKey is how React Query identifies and caches data. Think of it as the cache key.
// These are three different cache entries
useQuery({ queryKey: ['users'], queryFn: fetchAllUsers });
useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId) });
useQuery({ queryKey: ['users', userId, 'posts'], queryFn: () => fetchUserPosts(userId) });
Rules for queryKey:
- Use an array — always
- Put the most general item first, most specific last
- If the query depends on a variable (like
userId), include it in the key - React Query will automatically refetch when the key changes
// ✅ This automatically refetches when userId changes
function UserProfile({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['users', userId], // key includes userId
queryFn: () => fetchUser(userId),
});
return <div>{user?.name}</div>;
}
When userId changes — the key changes — React Query checks the cache for the new key. Cache miss? Fetches. Cache hit? Returns instantly.
No useEffect. No manual dependency tracking. Just the key.
useMutation — handling writes
useQuery is for reading data. useMutation is for creating, updating, and deleting.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const createUser = useMutation({
mutationFn: (newUser: NewUser) =>
fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
}).then(res => res.json()),
onSuccess: () => {
// Invalidate the users cache — next time someone reads 'users', it refetches
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
function handleSubmit(data: NewUser) {
createUser.mutate(data);
}
return (
<form onSubmit={e => { e.preventDefault(); handleSubmit({ name: 'Richa', email: 'r@example.com' }); }}>
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Create User'}
</button>
{createUser.isError && <p className="error">{createUser.error.message}</p>}
{createUser.isSuccess && <p className="success">User created!</p>}
</form>
);
}
invalidateQueries is the key pattern. After a successful mutation — mark the related cache as stale. Next time a component reads that data, React Query refetches in the background. The UI stays in sync with the server automatically.
Stale time and cache time — understanding the defaults
React Query has two important time settings:
staleTime — how long until cached data is considered stale (default: 0)
With staleTime: 0, data is immediately stale after fetching. React Query will refetch in the background on every query mount and window focus.
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 1000 * 60 * 5, // 5 minutes — don't refetch for 5 minutes
});
gcTime (garbage collection time) — how long unused data stays in cache (default: 5 minutes)
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 1000 * 60, // 1 minute fresh
gcTime: 1000 * 60 * 10, // keep in cache for 10 minutes after unmount
});
Setting staleTime appropriately is one of the biggest performance wins. Data that doesn't change often — user profile, product catalogue, config settings — can have a long staleTime. You get instant loads from cache and React Query quietly refreshes in the background.
A practical pattern — custom query hooks
Don't scatter useQuery calls everywhere in your components. Put them in custom hooks:
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()) as Promise<User[]>,
staleTime: 1000 * 60 * 2, // 2 minutes
});
}
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()) as Promise<User>,
enabled: !!userId, // don't run if userId is empty
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (user: NewUser) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user),
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
Now components are clean:
function UsersPage() {
const { data: users, isLoading, isError } = useUsers();
const createUser = useCreateUser();
if (isLoading) return <Spinner />;
if (isError) return <p>Something went wrong</p>;
return (
<div>
<button onClick={() => createUser.mutate({ name: 'Test', email: 'test@test.com' })}>
Add User
</button>
<ul>
{users?.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
</div>
);
}
Components don't know anything about fetching, caching, or invalidation. They just call a hook and render.
What React Query replaces — the before and after
Before React Query — manual server state:
function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
fetch('/api/users', { signal: controller.signal })
.then(res => res.json())
.then(data => { setUsers(data); setIsLoading(false); })
.catch(err => {
if (err.name === 'AbortError') return;
setError(err.message);
setIsLoading(false);
});
return () => controller.abort();
}, []);
// No caching. No deduplication. No background refetch. No retry.
// Fetches again on every mount. Stale data between navigations.
}
After React Query — 4 lines:
function UsersPage() {
const { data: users, isLoading, isError } = useUsers();
// Caching ✅ Deduplication ✅ Background refetch ✅ Retry ✅ Loading state ✅ Error state ✅
}
The before version has zero caching, no deduplication, no background refetch, no retry on failure. The after version gets all of that for free.
When to use React Query vs Zustand/Redux for server data
The answer is almost always: React Query for server data, Zustand/Redux for client state.
They don't compete — they complement each other:
React Query → anything that comes from or goes to an API
Zustand → UI state, user preferences, local app state
Context → auth, theme, locale — rarely-changing globals
Some teams use all three. Some just use React Query + Zustand and skip Redux entirely. Both are valid.
What you should never do is put API data into Redux manually when React Query exists. You're writing caching logic that React Query already solved — and solving it worse.
Quick summary
| Feature | Manual (useState + useEffect) | React Query |
|---|---|---|
| Loading state | Write it yourself | Built-in isLoading |
| Error state | Write it yourself | Built-in isError |
| Caching | None | Automatic |
| Deduplication | None | Automatic |
| Background refetch | None | Automatic on window focus |
| Retry on failure | None | 3 retries by default |
| Stale data handling | None | staleTime config |
| Cleanup / AbortController | Write it yourself | Handled internally |
Today's takeaway
Server state and client state are fundamentally different things — and they need different tools.
Server state has unique challenges: caching, revalidation, deduplication, background refetching, loading and error states. Managing all of that manually with useState and useEffect is reinventing the wheel — badly.
React Query (TanStack Query) was built specifically for server state. It handles all of that complexity so your components stay clean and your data stays fresh.
The mental shift: stop thinking of API data as state you manage. Think of it as a cache you borrow.

See you tomorrow for Day 10 — Designing your state layer: where to put what and why. We're tying all of week 2 together.

← Day 8 — Redux Toolkit → Day 10 — Designing Your State Layer
Part of the React System Design Series — 40 days, one topic per day.






