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.
"Global state" is rarely one thing. Treat each kind separately and you'll avoid the re-render avalanches and tangled prop drilling.
Categorize first
| Kind | Best home |
|---|---|
| Server data (API responses) | React Query / RTK Query / SWR |
| Authentication state | Context + cookie (HttpOnly for token) |
| Theme / locale / feature flags | Context (rarely changes) |
| Cross-component UI (modal open, sidebar collapsed) | Context for tree-local; Zustand for app-wide |
| Form state | React Hook Form / Formik / local useState |
| URL state (filters, page) | Router params (useSearchParams) |
| User preferences (persistent) | localStorage + a small store |
| Real-time collab state | CRDT / 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.
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:
// 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:
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:
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:
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
- Is it server data? → React Query / RTKQ.
- Is it URL state? → Router params.
- Is it form state? → React Hook Form / local state.
- Is it local to one component? → useState.
- Is it shared across siblings? → Lift to common parent or use Context.
- Is it app-wide and frequently updated? → Zustand / Jotai.
- 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.