React & Next.js · 6 min read

React State Management: Server State Is Not Client State

Why most React state bugs come from storing server data in client state, and how to pick between useState, Context, a store and a server-cache library.

By Praful Patel · Last updated
React State Management: Server State Is Not Client State - article cover
React State Management: Server State Is Not Client State - article cover

Short answer

Most React state bugs are one mistake wearing different costumes: server data copied into client state. Once a cached copy of a database row lives in useState, you own invalidation, refetching, deduplication and staleness — problems a server-state library already solved. Separate the two categories first; the choice of tool becomes obvious afterwards.

The “which state library should I use” question is usually premature. The prior question is what kind of state you actually have, because the four kinds have almost nothing in common.

Four categories, four different tools

Category Examples Right tool
Server state Products, orders, the current user’s profile TanStack Query, SWR, or RSC + server actions
URL state Filters, sort order, pagination, active tab Search params — the URL itself
Local UI state Is this dropdown open, input draft value useState, colocated
Global client state Theme, sidebar collapse, an unsaved multi-step form Zustand, Jotai, or Context

The category that causes the damage is the first one. Server state is not owned by your component — it is a cache of something authoritative elsewhere, and it can be stale the instant it arrives.

The anti-pattern, and what it costs

// Every one of these concerns is now yours to implement, forever.
const [orders, setOrders]   = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError]     = useState(null);

useEffect(() => {
  let cancelled = false;
  fetch('/api/orders')
    .then(r => r.json())
    .then(d => { if (!cancelled) setOrders(d); })
    .catch(e => { if (!cancelled) setError(e); })
    .finally(() => { if (!cancelled) setLoading(false); });
  return () => { cancelled = true; };
}, []);

What this code does not do: deduplicate two components requesting the same data, refetch when the window regains focus, retry a failed request, keep previous data visible while refetching, invalidate after a mutation elsewhere, or survive a remount without a fresh network round trip. Each is a real requirement that arrives later as a bug report.

const { data, isPending, error } = useQuery({
  queryKey: ['orders'],
  queryFn: () => fetch('/api/orders').then(r => r.json()),
  staleTime: 30_000,          // treat as fresh for 30s; no duplicate fetches
});

The library is not saving you keystrokes. It is supplying a cache with an invalidation model, which is the part that was missing.

URL state is state

Filters, sorting and pagination held in useState produce a page that cannot be bookmarked, shared or restored after a reload, and where the back button does the wrong thing. Putting them in search params fixes all of it at once and costs less code.

const params = useSearchParams();
const router  = useRouter();
const tag     = params.get('tag') ?? 'all';

function setTag(next) {
  const p = new URLSearchParams(params);
  next === 'all' ? p.delete('tag') : p.set('tag', next);
  router.replace(`?${p}`, { scroll: false });
}

It also means a server component can read the same value from searchParams and render the filtered result without any client fetch at all.

Choosing a client-state library

For the genuinely global, genuinely client-owned remainder — usually much smaller than expected — the practical differences:

  • Context — no dependency, but every consumer re-renders when any part of the value changes. Fine for values that rarely change, such as a theme. Poor for anything updating frequently.
  • Zustand — a small store with selector-based subscriptions, so a component re-renders only when the slice it selected changes. The pragmatic default for most applications.
  • Jotai — atomic state, composed bottom-up. Good when state is highly granular and derived.
  • Redux Toolkit — justified by strict conventions, time-travel debugging and large teams needing enforced structure. Rarely the right choice for a new mid-sized application.

With Context, splitting one provider into two — a stable value and a volatile one — removes most performance complaints without adding a dependency.

Server components change the calculation

When pages are server components, a large share of what used to be client state stops existing. Data is fetched during render, filters live in the URL, and mutations go through server actions that revalidate the affected paths. What remains is genuine interaction state: open panels, in-progress input, optimistic updates.

Applications built this way frequently need no global store at all. The boundary rules that make this work are covered in React Server Components, and the revalidation behaviour after a mutation in Next.js Caching Explained.

A decision procedure

  1. Does it come from a server? Use a server-state library, or fetch it in a server component.
  2. Should it survive a reload or be shareable by link? Put it in the URL.
  3. Is it used by one component and its immediate children? useState, colocated.
  4. Only what remains needs a store.

Applied honestly, step four is usually a handful of values — which is the point.

Questions people actually ask

Do I still need Redux?
Rarely for new applications. Most of what Redux historically managed was server state, which now belongs in a query cache, or URL state, which belongs in search params. Redux Toolkit remains defensible for large teams that want enforced structure and its debugging tooling, but it should be a deliberate choice rather than a default.

Context or Zustand?
Context for values that change rarely and are read widely, such as a theme or locale. Zustand once updates are frequent, because its selector subscriptions re-render only the components reading the changed slice, where Context re-renders every consumer on any change.

Is it wrong to fetch in useEffect?
It is not wrong, it is incomplete. It works, and then you re-implement deduplication, retries, focus refetching and invalidation one bug at a time. For a single trivial fetch it is fine; for anything an application depends on, use a cache that already handles those.

How should I handle form state?
Local state or an uncontrolled form for the fields, with validation running against a schema shared with the server. Do not put in-progress form values in a global store – it survives navigation in ways users do not expect and makes reset logic unnecessarily complicated.

How do I keep two components in sync after a mutation?
Invalidate the cache key rather than pushing the new value into both. With a server-state library, mutate then invalidate the affected queries; with server components, call revalidatePath or revalidateTag. Manual synchronisation between components is where the drift bugs come from.

← Back to all insights