Most Global State Is Not Global
A catalogue page kept cart, user, theme, filters, products and the search text in one app-wide context. Every keystroke in the search box produced a new context value, and around 300 components re-rendered. Typing lagged on mid-range phones, and filtered pages could not be shared by link.
The fix was not a better store. It was noticing that most of that state did not belong in a store at all. Server data belongs in a query cache, shareable state belongs in the URL, and most UI state is local. Below is how to sort state by kind, the tools for each, and how to keep the small amount of real global state from re-rendering the world.
- You wired Redux to hold the result of a single
fetch()call - You sync the same value to Context, the URL, and
localStoragewith threeuseEffects - Your Context provider re-renders the entire tree every keystroke
- You can't explain why your app needs a global store at all
The Kinds of State
Categorize every piece of state by who owns it:
flowchart TD
A["What kind of state?"] --> B{"Lives on server?"}
B -->|Yes — DB row, API result| C["TanStack Query<br/>or fetch in RSC"]
B -->|No — UI only| D{"Used by one component?"}
D -->|Yes| E["useState / useReducer"]
D -->|No| F{"Belongs in URL?"}
F -->|Yes — filters, tabs, page| G["useSearchParams /<br/>nuqs"]
F -->|No| H{"Cross-cutting (theme, auth)?"}
H -->|Yes, rarely changes| I["React Context"]
H -->|Yes, changes often| J["Zustand / Jotai"]
style C fill:#059669,color:#fff,stroke:#047857
style G fill:#0891b2,color:#fff,stroke:#0e7490
style J fill:#4f46e5,color:#fff,stroke:#4338ca
Three categories most people get wrong: (1) server cache state ends up in Redux when it should be in React Query — you reinvent caching, dedup, and invalidation badly. (2) URL state ends up in a store when it should be in ?filters=... — it breaks back/forward and shareable links. (3) Form stateends up globalized when it should be local — a form draft is one component's problem.
Putting Each Kind of State in Its Place
Step 1: useState / useReducer — the default
// 90% of state is one component's problem
function Modal() {
const [open, setOpen] = useState(false);
return <button onClick={() => setOpen(true)}>Open</button>;
}
// useReducer when transitions are non-trivial
type Action = { type: 'add'; item: Item } | { type: 'remove'; id: string } | { type: 'clear' };
function cartReducer(state: Item[], action: Action): Item[] {
switch (action.type) {
case 'add': return [...state, action.item];
case 'remove': return state.filter(i => i.id !== action.id);
case 'clear': return [];
}
}
function Cart() {
const [items, dispatch] = useReducer(cartReducer, []);
// ...
}Step 2: URL as state — useSearchParams + nuqs
'use client';
// nuqs gives type-safe, shallow-routed URL params
import { useQueryState, parseAsInteger, parseAsString } from 'nuqs';
export function ProductFilters() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [q, setQ] = useQueryState('q', parseAsString.withDefault(''));
// URL becomes ?page=2&q=shoes — back/forward, refresh, share all work
return (
<>
<input value={q} onChange={e => setQ(e.target.value || null)} />
<button onClick={() => setPage(p => p + 1)}>Next</button>
</>
);
}Step 3: Context done right — split value, never put state in the same provider as setters
// BAD — every consumer re-renders when value OR setter identity changes
const ThemeContext = createContext<{ theme: string; setTheme: (s: string) => void }>(null!);
// GOOD — split read and write contexts
const ThemeStateContext = createContext<string>('light');
const ThemeDispatchContext = createContext<(s: string) => void>(() => {});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light');
return (
<ThemeStateContext.Provider value={theme}>
<ThemeDispatchContext.Provider value={setTheme}>
{children}
</ThemeDispatchContext.Provider>
</ThemeStateContext.Provider>
);
}
// Components that only call setTheme don't re-render when theme changes
export const useTheme = () => use(ThemeStateContext);
export const useSetTheme = () => use(ThemeDispatchContext);Step 4: Zustand — when you genuinely need a client store
'use client';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
type CartStore = {
items: Item[];
add: (item: Item) => void;
remove: (id: string) => void;
total: () => number;
};
export const useCart = create<CartStore>()(
devtools(
(set, get) => ({
items: [],
add: (item) => set(s => ({ items: [...s.items, item] })),
remove: (id) => set(s => ({ items: s.items.filter(i => i.id !== id) })),
total: () => get().items.reduce((s, i) => s + i.price, 0),
}),
{ name: 'cart' },
),
);
// Subscribe to ONLY the slice you need — selector form prevents re-renders
function CartBadge() {
const count = useCart(s => s.items.length); // re-renders only when length changes
return <span>{count}</span>;
}Step 5: Jotai — atomic state for fine-grained subscriptions
'use client';
import { atom, useAtom, useAtomValue } from 'jotai';
const queryAtom = atom('');
const filterAtom = atom<'all' | 'active' | 'done'>('all');
// Derived atoms recompute lazily and are memoized
const filteredCountAtom = atom((get) => {
const q = get(queryAtom);
const f = get(filterAtom);
return computeCount(q, f);
});
function Counter() {
const count = useAtomValue(filteredCountAtom); // only re-renders when count changes
return <span>{count}</span>;
}How State Management Goes Wrong
1. Putting server data in Redux/Zustand. You end up reimplementing cache invalidation, refetching, dedup, and pagination — badly. TanStack Query (or fetching in Server Components) handles all of it.
2. Context for high-frequency updates. Every consumer re-renders when the provider value changes. Mouse position in Context will re-render your whole tree on every move. Use a store with selectors.
3. Globalizing form state. Forms are component-local. Use useActionState, React Hook Form, or TanStack Form. A global "form store" is almost always the wrong abstraction.
4. Storing what should be in the URL. Filters, tabs, pagination, search query — all belong in ? params so back/forward and link-sharing work. nuqs makes this trivial in App Router.
5. Premature global store.Adding Zustand on day one because "we'll need it" usually means you write Zustand selectors for things one component owns. Start with useState; promote to a store when two unrelated components need the same state.
6. Stale closures in selectors. A Zustand selector that captures a prop or other reactive value will read a stale version. Either pass the prop into the selector via the equality function, or pull the dependency out of the selector.
What I Would Choose
- The decision tree: server data → React Query / RSC. URL data → URL. Local UI →
useState. Global UI → store. - Reach for
useReducerwhen state transitions are non-trivial (4+ actions or interdependent fields). - Split Context into State and Dispatch when both change at different rates — or use a store.
- Default to Zustand for new client stores in 2026 — small API, devtools, no provider, selector-based subscriptions.
- Use Jotai when you have many tiny atoms that derive from each other (graph-shaped state).
- Keep Redux Toolkit only on legacy codebases or when you genuinely need its middleware ecosystem (RTK Query, sagas).
- Whenever you reach for a store, ask: "Could the URL hold this?" If yes, put it there.

