Server State vs Client State: Rethinking Frontend State Management#
For a long time, every React project I joined had the same architecture: a global Redux store holding everything — the logged-in user, the product list, the modal that was open, the form inputs, and a dozen isLoading flags. Half of the code in the store was hand-written caching logic for API data.
The shift that simplified my frontends the most was realising that most of that "state" was not client state at all. It was a cache of server state, and it needed different tools.
Two very different kinds of state#
Server state lives on the server. Your app only holds a snapshot of it:
- It can change without your app knowing (another user edits it).
- It needs to be fetched, cached, refreshed and invalidated.
- It can be stale.
Client state exists only in the browser:
- Which tab is selected, whether a dropdown is open.
- Form input before submission.
- UI preferences such as theme or a collapsed sidebar.
When both live in the same global store, you end up reinventing caching, deduplication and background refresh with reducers. That is a lot of code to maintain badly.
Use a server-state library for server state#
Libraries like TanStack Query handle the hard parts of server state for you:
1function ProjectList() {2 const { data, isPending, error } = useQuery({3 queryKey: ["projects", { status: "published" }],4 queryFn: () => api.getProjects({ status: "published" }),5 staleTime: 60_000,6 });7 8 if (isPending) return <ProjectListSkeleton />;9 if (error) return <ErrorState error={error} />;10 return <ProjectGrid projects={data} />;11}
That small hook gives you request deduplication across components, caching, background refetching when the window regains focus, retries, and garbage collection of unused data. Writing that correctly by hand takes weeks.
Mutations and invalidation#
Writes become simple too. After a mutation, invalidate the queries whose data it affects and let the library refetch:
1const queryClient = useQueryClient();2 3const publish = useMutation({4 mutationFn: (id: string) => api.publishProject(id),5 onSuccess: () => queryClient.invalidateQueries({ queryKey: ["projects"] }),6});
For interactions that must feel instant, add an optimistic update and roll back on error. Use it where latency is noticeable, such as toggles and likes, not everywhere.
Keep client state local by default#
Once server state moves out, the remaining client state is usually small. My order of preference:
- Local component state with
useState — most state belongs here. - Lifted state to the nearest common parent when siblings share it.
- URL state for anything that should survive a refresh or be shareable: filters, pagination, the selected tab.
- Context for rarely changing, widely needed values: theme, the current user, locale.
- A small global store (Zustand or similar) only for genuinely global, frequently changing client state.
URL state is underused. If a user filters a list and shares the link, the recipient should see the same filtered list:
1const searchParams = useSearchParams();2const status = searchParams.get("status") ?? "all";
Form state is client state with special needs: validation, dirty tracking, touched fields, submission status. A dedicated form library with schema validation (for example React Hook Form with Zod) keeps this out of your global store and makes validation rules reusable on the server.
What about Server Components?#
With React Server Components, a lot of server state never reaches client-side state at all. The server fetches it, renders it, and sends HTML. Client-side server-state libraries are still valuable for highly interactive views — dashboards, infinite lists, anything that refetches in the browser — but you may need them in fewer places.
A quick audit for your codebase#
Look at your global store and sort every slice into one of two buckets:
- Data that came from an API — move it to a server-state cache.
- UI state used by one component tree — move it into local state or the URL.
What remains is often small enough that you no longer need a heavyweight store.
Conclusion#
Most frontend state complexity comes from treating server data like client data. Put server state in a tool built for caching and invalidation, keep client state as local as possible, and use the URL for anything worth sharing. Your store gets smaller, your bugs get rarer, and your components get easier to reason about.