Server state is not client state, and treating them the same hurts
There are two kinds of state in a frontend app and they obey different rules. Client state is state your UI owns — a modal’s open flag, a form draft, the selected tab. It is synchronous, it is the source of truth, and only your app can change it. Server state is data that lives on a server and you merely hold a copy of — the user record, the product list, the order history. It is asynchronous, it can be changed by other users while you hold it, and it can go stale the moment you fetch it. The most common state-management mess comes from treating the second like the first: dumping fetched data into your Redux store as if you owned it, and then hand-rolling caching, refetching, and staleness — badly.
The bug: editing the cache as if it were the truth
When you treat fetched data as owned client state, you store it, then let the app mutate that stored copy directly, and you have quietly created a second source of truth that drifts from the server the moment anyone else changes the record:
// TRAP: server data stored and edited as if the client owned it
case "USER_NAME_CHANGED":
return { ...state, user: { ...state.user, name: action.name } };
// nothing here knows the server is the real owner — this copy can now lie
It looks fine until another tab, another user, or a failed save makes your stored copy disagree with reality, and now you are writing reconciliation code you never planned for.
The fix: name it a cache, and give it cache rules
The honest model is that server data in your store is a cache, and a cache has questions client state never does: how fresh is it, when do I refetch, what happens on error, how do I dedupe two components asking for the same thing. Encode that explicitly — the request lifecycle plus a fetched-at stamp — rather than pretending it is owned:
// server state modelled as what it is: a cache with status and freshness
const initial = { status: "idle", data: null, error: null, fetchedAt: 0 };
// REQUEST → loading; SUCCESS → { data, fetchedAt: now }; FAIL → error
// and a selector can decide "stale?" from fetchedAt, then trigger a refetch
Often the right tool is not your store at all
Because server state’s needs are so consistent — cache, revalidate, dedupe, refetch, retry — there are libraries built for exactly it (React Query, SWR, RTK Query), and reaching for one usually removes more code than it adds:
// the cache concerns handled for you; you just declare the fetch and its key
const { data, isLoading, error } = useQuery(["user", id], () => fetchUser(id), {
staleTime: 30_000, // trust the cache for 30s, then revalidate in the background
});
The rule of thumb is a clean split: keep client state (owned, synchronous, UI-only) in your store or component state, and treat server state as a cache, ideally with a query library that already solved caching correctly. Most “why is this data stale / duplicated / flickering” bugs trace back to one confusion — storing a borrowed copy as if it were owned truth. Name it a cache and its rules follow. The offline-first-list and retry-with-backoff exercises live entirely in server-state territory, which is where the difference between “owned” and “cached” stops being philosophy and starts preventing bugs.