Skip to content
React rc data 5 min read

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.

PropertyClient / UI stateServer state
OwnerYour componentA remote server
Source of truthThe component itselfThe backend
FreshnessAlways currentCan go stale at any moment
LifetimeLost on unmountPersists across sessions
Shared byUsually one componentMany components, tabs, users
AccessSynchronousAsynchronous (network)
Core needuseState / useReducerCaching, 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 customerId changes 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 haveReach forWhy
Local toggle, tab, modal flaguseStateOwned by the component, synchronous
Complex local transitionsuseReducerMany related local fields
Shared, synchronous client stateContext, Zustand, ReduxGlobal but still owned by the app
Remote data (reads)TanStack Query / SWRCaching, dedup, revalidation built in
Remote data (writes)TanStack Query mutationsOptimistic 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.
Last updated June 14, 2026
Was this helpful?