Server State vs Client State
TL;DR
The single most important framing for modern frontend state: most “state management” pain comes from treating server state and client state as the same problem. Server state is a cache of data that lives on a server — async, shared, owned elsewhere, and can go stale without you knowing. Client state is UI state you own outright — synchronous, local, ephemeral (open menus, form drafts, theme, selected tab). Put server state in a data-fetching/caching library (TanStack Query, RTK Query, SWR). Put client state in useState/Context/Zustand/Redux. Hand-rolling server data into Redux means reimplementing caching, dedup, and refetching — badly.
In depth
Define server state and client state.
| Server state | Client state | |
|---|---|---|
| Source of truth | a remote server | the client |
| Sync model | asynchronous (fetch) | synchronous |
| Ownership | shared with other clients | exclusive to this session |
| Staleness | can become stale silently | always current |
| Examples | user profile, product list, comments | modal open, form input, theme, sort order |
The mismatch: client-state tools assume the value is current and yours. Server data is neither — so it needs caching, invalidation, background refetch, and dedup, which is a different tool’s job.
Why is “put all the API data in Redux” an anti-pattern?
You end up manually writing what a server-state library gives you for free:
- caching keyed by request,
- request deduplication (two components asking for the same data → one fetch),
- background refetch / stale-while-revalidate,
- loading/error state per query,
- garbage-collecting unused data,
- retry/backoff.
That’s hundreds of lines of thunks, loading flags, and normalized reducers reimplementing a cache. Use a purpose-built cache instead.
What does a server-state library actually give you?
Using TanStack Query as the example:
function Profile({ id }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
// serve from cache for 30s before refetching
staleTime: 30_000,
});
if (isLoading) return <Spinner />;
return <h1>{data.name}</h1>;
}Cache keyed by queryKey, automatic dedup, background revalidation, and shared cache across components — no reducers, no loading booleans by hand. See TanStack Query — Cache Keys, Invalidation, Prefetch.
So what’s left for a client-state library?
The genuinely-local stuff: which tab is open, multi-step form drafts, a shopping-cart UI before checkout, theme/locale, “is the sidebar collapsed.” This is small, synchronous, and often fine in useState + Context, or a lightweight store (Zustand/Jotai) when shared widely. Reach for Redux only when client state is genuinely complex (see Choosing a State Library).
Where do RTK Query and TanStack Query fit?
Both are server-state libraries. RTK Query (RTK Query) is the Redux Toolkit answer — pick it if you’re already on Redux and want one store/devtools. TanStack Query is framework-agnostic and the default if you have no other reason to run Redux. Using either means you stop storing server data in your client store.
What about derived state — store it or compute it?
Don’t store what you can derive. totalPrice = items.reduce(...), filteredList = list.filter(...) should be computed at render (memoize with useMemo/selectors if expensive), not duplicated into state where it can drift out of sync with its source. Storing derived state is a top cause of “the count is wrong” bugs.
// Two sources of truth. They will disagree.
const [items, setItems] = useState([])
// updated... everywhere?
const [total, setTotal] = useState(0)
// One source of truth. It cannot disagree.
const total = useMemo(
() => items.reduce((n, i) => n + i.price * i.qty, 0),
[items],
)The first version needs every mutation site to remember to update total. The
bug is not that someone forgets once — it is that a new mutation site added in
six months has no way of knowing it should.
Gotchas / edge cases
- The boundary is occasionally fuzzy — optimistic UI temporarily holds server-shaped data in client state, then reconciles. That’s fine; it’s the exception, handled by the server-state lib’s mutation API (Optimistic Updates and Rollback).
- Auth/session token is borderline — usually client state (a value you hold) backed by a server check; keep the token out of localStorage if you can (see Frontend Security — Senior Interview Prep).
- Form state is client state until submit; don’t sync every keystroke to a global store.
- Don’t double-cache — if TanStack Query already caches the user, don’t also copy it into Redux “to be safe.” One source of truth.
What a senior is expected to say 4
- “Server state is a cache of remote data; client state is UI state I own. They need different tools.”
- “Putting API responses in Redux by hand reimplements caching, dedup, and refetch — I use TanStack Query or RTK Query for that.”
- “Client store is then only for genuinely-local UI state, often just
useState/Context or a small Zustand store.” - “I don’t store derived state — I compute it and memoize if needed.”
Cross-references
- TanStack Query (the canonical server-state tool): TanStack Query — Cache Keys, Invalidation, Prefetch
- RTK Query (Redux’s server-state layer): RTK Query
- Choosing a client-state library: Choosing a State Library
- Optimistic updates (the fuzzy boundary): Optimistic Updates and Rollback
Further reading
- TanStack Query — “Does this replace Redux?”: https://tanstack.com/query/latest/docs/framework/react/guides/does-this-replace-client-state
- Kent C. Dodds — “Application State Management with React”: https://kentcdodds.com/blog/application-state-management-with-react