Server State vs Client State
Not all state is created equal. The single most clarifying idea in modern data fetching is that the data your app holds falls into two very different categories: client state, which your component owns and controls, and server state, which is really a cache of data that lives somewhere else. Treating them the same — cramming both into useState or a global Redux store — is the root cause of most stale-data bugs, redundant requests, and tangled loading logic. This page draws the line precisely and explains which tool fits each side.
Two kinds of state
Client state (also called UI state or local state) is anything your component authoritatively owns: a toggled dropdown, the current tab, a form’s draft value, a “dark mode” preference. It is always correct because you set it. Nobody else can change it behind your back, and when the component unmounts it simply disappears — which is usually fine, because it only ever described the UI.
Server state is different in kind. It is data that originates on a remote server — a list of orders, the signed-in user, search results. Your component does not own it; it holds a snapshot. The moment you fetch it, it can already be out of date because another user, another tab, or a background job may have changed the underlying record. You do not control freshness; you can only re-synchronize.
| Property | Client / UI state | Server state |
|---|---|---|
| Owner | Your component | A remote server |
| Source of truth | The component itself | The backend |
| Freshness | Always current | Can go stale at any moment |
| Lifetime | Lost on unmount | Persists across sessions |
| Shared by | Usually one component | Many components, tabs, users |
| Access | Synchronous | Asynchronous (network) |
| Core need | useState / useReducer | Caching, dedup, revalidation |
Why mixing them hurts
When you store fetched data in useState inside an effect, you are quietly signing up to reimplement an entire caching layer by hand. Consider what a single naive fetch leaves unsolved.
import { useEffect, useState } from "react";
function Orders({ customerId }) {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`https://api.example.com/customers/${customerId}/orders`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setOrders)
.catch(setError)
.finally(() => setLoading(false));
}, [customerId]);
if (loading) return <p>Loading orders…</p>;
if (error) return <p>Failed: {error.message}</p>;
return <ul>{orders.map((o) => <li key={o.id}>{o.total}</li>)}</ul>;
}
That works for one component in isolation, but the problems compound the moment the app grows:
- No deduplication. If three components mount and each fetches the same orders, you fire three identical requests.
- No sharing. Each component keeps its own copy in its own
useState, so they drift apart. - No revalidation. The data never refreshes when the user returns to the tab or comes back online.
- Race conditions. If
customerIdchanges quickly, a slow earlier response can overwrite a newer one. - No cache lifetime. Navigate away and back and you re-fetch from scratch, showing a spinner you did not need to.
Moving the same data into a global store like Redux does not fix this — it just relocates the problem. You now manually write actions, reducers, loading flags, and invalidation logic for every endpoint. Redux is excellent for client state that is genuinely shared and synchronous; it was never designed to be an async cache.
Rule of thumb: if you can answer “where does this data really live?” with “on the server,” it is server state, and
useState/Redux is the wrong home for it. Reach for a server-state cache instead.
Which tool fits which state
Match the tool to the nature of the data rather than to habit.
| State you have | Reach for | Why |
|---|---|---|
| Local toggle, tab, modal flag | useState | Owned by the component, synchronous |
| Complex local transitions | useReducer | Many related local fields |
| Shared, synchronous client state | Context, Zustand, Redux | Global but still owned by the app |
| Remote data (reads) | TanStack Query / SWR | Caching, dedup, revalidation built in |
| Remote data (writes) | TanStack Query mutations | Optimistic updates + cache invalidation |
The verbose effect above collapses into a single hook once you treat the data as what it is — a cache.
import { useQuery } from "@tanstack/react-query";
function Orders({ customerId }) {
const { data, error, isPending } = useQuery({
queryKey: ["orders", customerId],
queryFn: () =>
fetch(`https://api.example.com/customers/${customerId}/orders`).then(
(r) => r.json(),
),
});
if (isPending) return <p>Loading orders…</p>;
if (error) return <p>Failed: {error.message}</p>;
return <ul>{data.map((o) => <li key={o.id}>{o.total}</li>)}</ul>;
}
The library now handles dedup (the queryKey shares one cache entry across components), background revalidation, retries, and race-safe responses. Crucially, the two kinds of state can coexist cleanly: server data comes from the query cache, while a local “show details” toggle stays in useState. They no longer fight for the same container.
function OrderRow({ order }) {
const [expanded, setExpanded] = useState(false); // client state
return (
<li onClick={() => setExpanded((v) => !v)}>
{order.total}
{expanded && <pre>{JSON.stringify(order, null, 2)}</pre>}
</li>
);
}
Best Practices
- Ask “where does the source of truth live?” — server means use a server-state cache; the component means use
useState. - Never store fetched data in a global Redux/Context store as if it were client state; it will go stale and force you to hand-roll caching.
- Use a dedicated cache (TanStack Query or SWR) for all remote reads so dedup, revalidation, and retries come for free.
- Keep purely local concerns (toggles, drafts, tab selection) in
useState/useReducer— they do not belong in the server cache. - Let the two layers coexist: a component can read server state from a query and own its UI state locally at the same time.
- Use a stable, descriptive
queryKey(or SWR key) so the same server data is shared rather than duplicated.