Skip to main content
Back to module
Front-End Engineering · Front-End Architecture

Server and client state

Reading20mLesson 3 of 3 in this module

Choose between local, global and server state, and pick the simplest tool for each.

Why it matters

"Which state management library should we use?" is one of the most argued questions in front-end work. The better question is: what kind of state is this? Once you sort state into a few kinds, most of the decision makes itself, and a lot of complicated global stores turn out to be unnecessary.

Four kinds of state

  1. Local UI state. Belongs to one component: is this dropdown open, what has the user typed in this field, which tab is selected. Use useState or useReducer in the component that owns it.
  2. Shared client state. Lives only in the browser but is needed in several places: the current theme, a multi-step form's draft, items in a cart before checkout. Lift it to the closest common parent, or put it in React context or a small store (Zustand, Redux Toolkit, Jotai) if many distant components need it.
  3. Server state. Data whose source of truth is the server: the course list, the signed-in user's profile, notifications. You only hold a cached copy of it, which can go stale when someone else changes it.
  4. URL state. Anything that should survive a reload or be shareable: the current page, search query, filters, the open tab (?tab=security). The URL is the store; read it with your router.

Two extra rules: derived values (a total computed from cart items, a filtered list) are not state at all, so compute them during render. And form state that is only read on submit can live in the DOM (uncontrolled inputs read through FormData).

Server state is different

Server state has problems client state does not:

  • It is asynchronous: you must handle loading and error.
  • It can be stale: another tab, user or job may have changed it.
  • It is shared: three components showing the user's name should not make three requests.
  • It needs invalidation: after you post a comment, the comment list must refresh.

Developers often copy server data into a global store with useEffect and then hand-write caching, deduplication and refetching. Libraries such as TanStack Query (React Query), SWR, or a framework's data loaders do this for you:

function useCourses() {
  return useQuery({
    queryKey: ["courses"],
    queryFn: () => fetch("/api/courses").then((r) => r.json()),
    staleTime: 60_000,          // treat data as fresh for a minute
  });
}

function CourseList() {
  const { data, isPending, isError } = useCourses();
  if (isPending) return <SkeletonList />;
  if (isError) return <Alert>Could not load courses.</Alert>;
  return data.map((c) => <CourseCard key={c.slug} course={c} />);
}

Every component that calls useCourses() shares one cached request. After a mutation you invalidate the key (queryClient.invalidateQueries({ queryKey: ["courses"] })) and the library refetches. Many apps that adopt a server-state library find their global store shrinks to a theme toggle and a few UI flags.

A decision guide

Ask these in order:

  1. Can it be computed from other state or props? Then compute it.
  2. Should it survive a reload or be shareable by link? Put it in the URL.
  3. Does it come from the server? Use your server-state tool (or framework loader).
  4. Is it used by one component (or one small subtree)? Keep it local, lifting it up only as far as needed.
  5. Is it client-only and used far apart? Context for rarely changing values (theme, locale, signed-in user), a small store for frequently changing ones.

Worked example

A course catalog page has a search box, a level filter, the list of courses, a "dark mode" toggle in the header and a "compare" tray where learners pin up to three courses.

  • Search text and level filter: URL state (/courses?q=react&level=mid-level), so the results can be shared.
  • Course list: server state, keyed by the filters: queryKey: ["courses", { q, level }].
  • Number of results: derived from the list; never stored.
  • Dark mode: shared client state in context (rarely changes, read everywhere), persisted to localStorage as a convenience.
  • Compare tray: shared client state used by the card buttons and the tray; a tiny store or context with a reducer.
  • Whether the filter drawer is open on mobile: local state in the drawer component.

Performance notes

Context re-renders every consumer when its value changes. That is fine for a theme, but a context holding a fast-changing value (a text field, a timer) can re-render half the app. Split contexts by how often they change, or use a store with selectors so components subscribe only to the slice they read.

Common mistakes

  • Mirroring props into state with useEffect. It creates two sources of truth that drift. Use the prop, or compute from it.
  • Putting server data in a global store and hand-writing refetch logic.
  • Storing derived data, such as filteredCourses, which goes stale when the filter changes.
  • Global by default. A modal's open flag in Redux makes every change harder to trace.
  • Forgetting invalidation after a mutation, so the UI shows old data.

Quick recap

  • Sort state into local, shared client, server and URL state; compute derived values.
  • Use a server-state library or framework loaders for remote data: caching, deduping and invalidation come for free.
  • Keep state as local as possible and lift it only as far as needed.
  • Use context for slow-changing global values and a store with selectors for fast-changing ones.

Try it

Open the Do tab and classify the state in a few real scenarios.

Also in this lesson: