The Great State Conflation: Server Cache vs. Client UI
For nearly a decade, React applications treated all data identically: fetch an array of records from a REST endpoint, dispatch an action, and store the array in a single monolithic global store (such as Redux). As applications grew, this pattern created massive complexity: loading spinners entangled with business logic, stale data bugs when another browser tab modified a record, and thousands of lines of reducer boilerplate just to perform simple CRUD operations.
The root problem was a failure to recognize that applications manage two completely distinct types of state:
- Server State: Data that is owned by the remote database (user accounts, billing histories, article lists). It is asynchronous, can be changed remotely without your application's knowledge, and requires caching, background revalidation, and optimistic updates.
- Client State: Ephemeral UI state that is purely local to the browser session (modal open/closed flags, active sidebar tabs, form draft inputs, dark mode toggles). It is synchronous, reliable, and completely controlled by the client.
1. Eliminating Boilerplate with TanStack Query v5
TanStack Query (formerly React Query) treats server state not as global variables, but as an asynchronous distributed cache. It handles automatic background refetching on window focus, request deduplication, garbage collection of unused endpoints, and structural sharing out of the box:
// src/features/projects/api/useProjects.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { Project } from "../types";
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
list: (filters: { status?: string }) => [...projectKeys.lists(), filters] as const,
details: () => [...projectKeys.all, "detail"] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
};
// Strongly-typed query hook
export function useProjects(status?: string) {
return useQuery({
queryKey: projectKeys.list({ status }),
queryFn: async (): Promise => {
const params = new URLSearchParams(status ? { status } : {});
const res = await fetch(`/api/projects/?${params}`);
if (!res.ok) throw new Error("Failed to load projects");
return res.json();
},
staleTime: 1000 * 60 * 5, // Consider data fresh for 5 minutes
gcTime: 1000 * 60 * 30, // Keep unused cache in memory for 30 minutes
});
}
2. Streamlined Client State with Zustand
For state that genuinely belongs to the client session—such as selected table rows, active filters, or UI theme configuration—Zustand provides a minimal, hook-based store without Context re-render penalties or boilerplate:
// src/stores/useUiStore.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
interface UiState {
sidebarOpen: boolean;
selectedProjectIds: Set;
theme: "dark" | "light";
toggleSidebar: () => void;
selectProject: (id: string) => void;
deselectProject: (id: string) => void;
clearSelection: () => void;
setTheme: (theme: "dark" | "light") => void;
}
export const useUiStore = create()(
persist(
(set) => ({
sidebarOpen: true,
selectedProjectIds: new Set(),
theme: "dark",
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
selectProject: (id) =>
set((s) => ({
selectedProjectIds: new Set(s.selectedProjectIds).add(id),
})),
deselectProject: (id) =>
set((s) => {
const next = new Set(s.selectedProjectIds);
next.delete(id);
return { selectedProjectIds: next };
}),
clearSelection: () => set({ selectedProjectIds: new Set() }),
setTheme: (theme) => set({ theme }),
}),
{
name: "devmanue-ui-storage",
storage: createJSONStorage(() => localStorage),
// Only persist theme and sidebar preference, not transient table selections
partialize: (state) => ({
sidebarOpen: state.sidebarOpen,
theme: state.theme,
}),
}
)
);
3. Optimistic Mutations: Zero Perceived Latency
Modern applications should not display a loading spinner for routine operations like starring an item or updating a status. With TanStack Query, you update the local cache immediately, apply the change visually, and automatically roll back if the network call fails:
// src/features/projects/api/useUpdateProjectStatus.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { projectKeys } from "./useProjects";
import type { Project } from "../types";
export function useUpdateProjectStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, status }: { id: string; status: Project["status"] }) => {
const res = await fetch(`/api/projects/${id}/status/`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!res.ok) throw new Error("Status update failed");
return res.json();
},
// When mutation is called:
onMutate: async ({ id, status }) => {
// Cancel outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: projectKeys.all });
// Snapshot previous value for rollback
const previousProjects = queryClient.getQueryData(projectKeys.lists());
// Optimistically update the cache
queryClient.setQueriesData({ queryKey: projectKeys.lists() }, (old) => {
if (!old) return [];
return old.map((p) => (p.id === id ? { ...p, status } : p));
});
return { previousProjects };
},
// If mutation fails, restore snapshot
onError: (err, variables, context) => {
if (context?.previousProjects) {
queryClient.setQueriesData({ queryKey: projectKeys.lists() }, context.previousProjects);
}
},
// Always invalidate after error or success to synchronize with backend reality
onSettled: () => {
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
},
});
}
4. Architectural Comparison
| Dimension | Legacy Monolithic Redux | Modern Dual-Tier Architecture |
|---|---|---|
| Server State | Manual actions, reducers, and loading flags | TanStack Query (declarative caching, deduplication) |
| Client UI State | Merged into the same global store | Zustand (atomic, localized hooks) |
| Boilerplate | Extremely high (types, actions, reducers, selectors) | Minimal (plain async functions + atomic stores) |
| Stale Data Defense | Manual polling or outdated views | Automatic background revalidation on window focus |
| Component Re-renders | Requires complex memoized selectors | Automatic selective subscriptions |
For related production architectures and system implementations, explore these companion guides:
- Hypermedia Over SPAs: Building Reactive Web Apps with Django & HTMX — Evaluate server-rendered hypermedia alternatives to complex client-side SPA state models.
- Zero-CLS & Sub-Second LCP in React Virtualized Grids — Feed query caches into high-density virtualized list components without layout shifts.
- Advanced TypeScript for Enterprise Domain Models — Type application state models strictly with branded types and discriminated unions.
Key Architectural Takeaways
- Draw the Boundary: Keep remote server data out of client state managers; use TanStack Query as an asynchronous cache layer.
- Keep Client Stores Lean: Use Zustand strictly for interactive, synchronous UI variables (dialogs, themes, draft inputs).
- Delight Users with Optimism: Leverage
onMutaterollback snapshots to make updates feel instantaneous while preserving backend data consistency.