Best Practices
Jan 2026·7 min read

Scalable React State Management: When to Use Local, Context, Zustand, or Server State

Solving context re-render thrashing, decoupling UI components from network fetching with SWR/React Query, and establishing maintainable state boundaries.

LEP
Lelianto Eko PradanaAI & Full Stack Engineer · Indonesia

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 CategoryUse CaseRecommended Solution
Local Component StateModals, dropdown toggles, active tabs, form fieldsuseState, useReducer
Server / Cache StateRemote API data, pagination, background syncTanStack Query (React Query), SWR
Global Client StateUser preferences, theme, cart items, active sessionsZustand, Jotai
URL / Route StateSearch filters, active page, sorting parametersNext.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:

tsx
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

  1. 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.
  2. Synchronize filters with the URL. Putting active pagination and search queries into query strings (?search=keyword&page=2) makes pages shareable and bookmarkable.
  3. Never duplicate server data in client stores. Fetch server data using dedicated query hooks and consume it directly.
  4. Define ownership and invalidation. For every shared value, document who can change it, how long it stays valid, and what event invalidates it.
  5. Measure render cost. Use React DevTools Profiler before introducing a store. A state library cannot fix an expensive component tree by itself.