Back to React
React
medium
mid

How do you manage global state efficiently in a React application?

Separate concerns. Server state (API responses) → React Query / RTKQ / SWR (caching, dedup, invalidation). Client global state (theme, auth, feature flags) → Context for rarely-changing values, Zustand/Jotai for frequently-changing. URL state → router params. Local state → useState. Don't put everything in one big global store; split by domain. Avoid Context for high-frequency updates — every consumer re-renders. Use selectors / atomization for granular subscription.

9 min read·~5 min to think through

"Global state" is rarely one thing. Treat each kind separately and you'll avoid the re-render avalanches and tangled prop drilling.

Categorize first

KindBest home
Server data (API responses)React Query / RTK Query / SWR
Authentication stateContext + cookie (HttpOnly for token)
Theme / locale / feature flagsContext (rarely changes)
Cross-component UI (modal open, sidebar collapsed)Context for tree-local; Zustand for app-wide
Form stateReact Hook Form / Formik / local useState
URL state (filters, page)Router params (useSearchParams)
User preferences (persistent)localStorage + a small store
Real-time collab stateCRDT / OT library + subscription

Server data ≠ global state

The most common mistake: putting API responses in Redux. React Query / RTKQ / SWR handle:

  • Caching by query key.
  • Dedup of concurrent requests.
  • Refetch on focus / reconnect / interval.
  • Tag-based invalidation on mutations.
  • Optimistic updates.
  • Stale-while-revalidate.

You're not "managing" server state — you're caching it. Use a library.

jsx
const { data, isLoading } = useQuery({
  queryKey: ['user', id],
  queryFn: () => api.getUser(id),
});

Context — the right cases

Good for:

  • Theme, locale, current user — set once, read by many, changes rarely.
  • Auth state — same.
  • Configuration (feature flags, env).

Bad for:

  • Counters, frequently-changing UI state.
  • Anything that re-renders dozens of components on every update.

The reason: Context triggers a re-render of every consumer when the value changes. If your value is an object recreated each render ({ user, setUser }), every consumer re-renders on every parent render.

Fix: memoize the value, or split contexts:

jsx
// One context for value, one for setter — setter rarely changes
<UserValueContext.Provider value={user}>
  <UserSetterContext.Provider value={setUser}>
    {children}
  </UserSetterContext.Provider>
</UserValueContext.Provider>

Or compose with children-as-prop pattern so children aren't re-created on Provider re-render.

Zustand / Jotai — fine-grained reactivity

For app-wide state with many small slices that change independently:

jsx
import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  user: null,
  inc: () => set(s => ({ count: s.count + 1 })),
  setUser: (u) => set({ user: u }),
}));

function Counter() {
  const count = useStore(s => s.count);  // subscribes only to count
  const inc = useStore(s => s.inc);
  return <button onClick={inc}>{count}</button>;
}

Components subscribe via selectors; only those whose selected slice changed re-render. No Context cascade.

Jotai works similarly with atom-based subscriptions:

jsx
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);

Each atom is independently subscribed; updates only re-render its subscribers.

Redux Toolkit — when it makes sense

  • Existing Redux codebase.
  • Need DevTools time travel for debugging complex flows.
  • Team prefers explicit reducers + actions.
  • RTK Query handling all server state.

For new apps with no Redux constraint, Zustand or Jotai + React Query usually win on simplicity.

Patterns that scale

1. Compose with children prop. Avoid re-render cascades by passing children stably:

jsx
function App() {
  return (
    <ThemeProvider>
      <AuthProvider>
        <Router />  {/* stable from outer render — providers don't re-render it */}
      </AuthProvider>
    </ThemeProvider>
  );
}

2. Split contexts by update frequency.

3. Lift state down, not up. State used by one subtree belongs in that subtree.

4. URL is state. Filters, search, pagination, sort — put them in query params. Bonus: shareable links, browser back/forward works.

Anti-patterns

  • One mega-Redux store with everything in it → re-render storms.
  • Context for high-frequency data (mouse position, scroll) → every consumer re-renders.
  • Inline object as Context value → every consumer re-renders on every parent render.
  • Storing server data in client state + manually refetching → reinventing React Query badly.
  • Global state for local concerns (form input on one page) → wider blast radius than needed.

Decision flow

  1. Is it server data? → React Query / RTKQ.
  2. Is it URL state? → Router params.
  3. Is it form state? → React Hook Form / local state.
  4. Is it local to one component? → useState.
  5. Is it shared across siblings? → Lift to common parent or use Context.
  6. Is it app-wide and frequently updated? → Zustand / Jotai.
  7. Is it app-wide and rarely updated? → Context.

Mental model

State has lifecycles. Server data is owned by the server; the client caches it. URL is state visible to users and bookmarkable. Component-local state should stay local. Truly shared state needs a tool that scales to fine-grained subscriptions. Treat global state as a last resort, not a default.

Follow-up questions

  • When does Context become a perf problem?
  • How does Zustand avoid the Context re-render cascade?
  • What goes in URL state vs client state?
  • Why prefer React Query over storing server data in Redux?

Common mistakes

  • Server data in Redux — reinvents React Query badly.
  • Inline object as Context value — every consumer re-renders.
  • Mega-store for everything — re-render storms.
  • Context for high-frequency updates.
  • Filters in client state instead of URL params.
  • Lifting state too high — wider blast radius than needed.

Performance considerations

  • Right state placement is the biggest React perf lever after rendering strategy. Wrong state placement creates re-render storms that dominate frame budget. Granular subscriptions (Zustand, Jotai) avoid the Context cascade. Server-state caching (React Query) eliminates redundant fetches and re-renders.

Edge cases

  • useSyncExternalStore for tearing-free concurrent rendering with external stores.
  • Selector equality functions (shallowEqual, deep) — wrong equality causes missed re-renders or thrash.
  • Server Components don't have client state — state lives in client components only.
  • Persisting state to localStorage requires rehydration on mount — handle the initial mismatch.
  • Cross-tab sync requires storage events or BroadcastChannel.

Real-world examples

  • Linear: Zustand for app state + IndexedDB for persistent + custom sync.
  • Excalidraw: minimal client state, mostly in one canonical store.
  • Notion: complex layered state with optimistic updates.
  • Redux Toolkit + RTKQ for enterprise React+Redux apps.

Senior engineer discussion

Seniors categorize state by lifecycle before picking a tool. They use React Query for server state by default, Context only for rarely-changing config, and Zustand/Jotai when fine-grained subscription is needed. They also keep state placement as narrow as possible to limit re-render blast radius.

Related questions