Introduction
One of the most frequent architectural mistakes in modern React applications is treating all data as "global client state". Storing server responses, UI toggle states, form inputs, and user profiles inside a single monolithic store leads to unnecessary re-renders, complex synchronization bugs, and brittle component contracts.
To build scalable frontend systems, state must be classified and assigned to its proper scope. Keep the owner as close as possible to the components that mutate the value, and derive everything else instead of copying it.
1. The Four Categories of Frontend State
| State Category | Use Case | Recommended Solution |
|---|---|---|
| Local Component State | Modals, dropdown toggles, active tabs, form fields | useState, useReducer |
| Server / Cache State | Remote API data, pagination, background sync | TanStack Query (React Query), SWR |
| Global Client State | User preferences, theme, cart items, active sessions | Zustand, Jotai |
| URL / Route State | Search filters, active page, sorting parameters | Next.js useSearchParams, nuqs |
2. Preventing Context Re-Render Thrashing
React Context is a dependency injection mechanism—not a dedicated state management library. Updating a context value triggers a re-render in *every* component consuming that context, regardless of whether the consumed slice changed.
Bad Pattern (Monolithic Context): ```tsx // Every consumer re-renders whenever ANY property changes! const AppContext = createContext<{ user: User; theme: string; cart: CartItem[] }>(null!); ```
Scalable Pattern (Zustand Atomic Selectors): ```typescript import { create } from "zustand"; interface UserStore { user: User | null; theme: "light" | "dark"; setTheme: (theme: "light" | "dark") => void; } export const useUserStore = create<UserStore>((set) => ({ user: null, theme: "light", setTheme: (theme) => set({ theme }), })); // Component only re-renders when theme changes! export function ThemeToggleButton() { const theme = useUserStore((state) => state.theme); const setTheme = useUserStore((state) => state.setTheme); return ( <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}> Current theme: {theme} </button> ); } ```
3. Decoupling Server State with TanStack Query
Do not sync server fetch results into global Redux or Zustand stores manually. Dedicated server state managers handle caching, deduplication, polling, and optimistic updates automatically:
import { useQuery } from "@tanstack/react-query";
export function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUserById(userId),
staleTime: 1000 * 60 * 5, // Cache valid for 5 minutes
});
if (isLoading) return <div>Loading profile...</div>;
if (error) return <div>Failed to load profile.</div>;
return <div>Welcome back, {user.name}</div>;
}4. Architecture Guidelines
- Keep state as close to where it is used as possible. If only one modal uses an open/close toggle, keep it in local
useState. - Synchronize filters with the URL. Putting active pagination and search queries into query strings (
?search=keyword&page=2) makes pages shareable and bookmarkable. - Never duplicate server data in client stores. Fetch server data using dedicated query hooks and consume it directly.
- Define ownership and invalidation. For every shared value, document who can change it, how long it stays valid, and what event invalidates it.
- Measure render cost. Use React DevTools Profiler before introducing a store. A state library cannot fix an expensive component tree by itself.