First 50 KB decision of every React project — Redux? Zustand? Jotai? Context? "Just use useState" — then props drilling. Switch to Context? — then re-render storms. This guide covers each tool's trade-offs, the server-state vs client-state split, and when "no library" is the right answer.
Local vs Global — The First Question
Local state (useState):
- Used by one component + some children
- Simple — useState alone is enough
- Examples: form input, modal open/close, hover state
Global state:
- Shared across pages or distant components
- Examples: logged-in user, theme, cart, notifications
→ 95% start local. Lift to a store only when global need is clear.Context API — Not a Redux Replacement
Common usage:
const UserContext = createContext(null);
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{user, setUser}}>
<Header /> ← every child can access user
<Main />
</UserContext.Provider>
);
}
Problem — when Context value changes:
- Every descendant using useContext re-renders
- The object identity is fresh per render (= new reference) → re-render guaranteed
// ❌ value is a new object every render
<UserContext.Provider value={{user, setUser}}>
// ✓ Stabilize with useMemo
const value = useMemo(() => ({user, setUser}), [user]);
<UserContext.Provider value={value}>
// Still: when user changes, every consumer re-renders
→ If only one component uses user.name, others still re-render
→ Context is for "avoiding prop drilling". Bad for frequently-changing state.
Dedicated stores (Redux / Zustand / Jotai) do fine-grained subscription.Redux — The Classic Heavyweight
// Single store + action + reducer
const store = createStore(rootReducer);
store.dispatch({type: "SET_USER", payload: user});
// React integration (react-redux)
const user = useSelector(state => state.user);
Pros:
- Strong pattern (action / reducer / selector)
- DevTools (time-travel debugging)
- Middleware (saga, thunk)
- Selectors trigger re-render only for changed slices
Cons:
- High boilerplate (action types, reducers, selectors)
→ Redux Toolkit (RTK) reduces this
- Steep learning curve
- "Everything in the store" anti-pattern temptation
Modern: Redux Toolkit (RTK) + RTK Query — keep Redux strengths, less boilerplateZustand — The Minimal Camp
import {create} from "zustand";
const useUserStore = create((set) => ({
user: null,
setUser: (user) => set({user}),
}));
// In a component:
const user = useUserStore(state => state.user);
const setUser = useUserStore(state => state.setUser);
Pros:
- Very simple (no action types, just call set)
- Small bundle (~1 KB)
- TypeScript-friendly
- Fine-grained subscription via selectors
Cons:
- Redux DevTools integration possible but not default
- Smaller middleware ecosystem than Redux
→ Recommended first choice for new projects (2024+ trend).Jotai — Atomic State
import {atom, useAtom} from "jotai";
const countAtom = atom(0);
const doubledAtom = atom(get => get(countAtom) * 2); // derived
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [doubled] = useAtom(doubledAtom); // auto-recompute
return <button onClick={() => setCount(count+1)}>{count} → {doubled}</button>;
}
Pros:
- "Atomic" model — graph of tiny atoms (spiritual successor to Recoil)
- Automatic dependency tracking (derived atoms)
- Tiny (~1 KB)
Cons:
- Mental model takes adjusting
- Atom management can complicate large apps
Use cases: forms or many small reactive units.Server State vs Client State — The Biggest Insight
Client state: transient UI state
- modal open, current tab, selected filter
- reset-on-refresh is fine
- useState / useReducer / Zustand
Server state: client-side cache of server data
- user info, post lists, search results
- server is source of truth
- need cache / refetch / mutation invalidation
→ Handling both with the same tool (e.g. Redux) means manually
managing server-state cache / loading / error (boilerplate explosion).
TanStack Query (formerly React Query) — server-state specialist:
const {data, isLoading, error} = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then(r => r.json()),
});
// cache + loading + error + refetch + stale-while-revalidate, automatic
SWR (Vercel) — similar tool.
Modern trend:
- server state: TanStack Query or SWR
- client state: useState (local) + Zustand (global)
- Clean separation → less boilerplate + better UXWhen "No Library" Is the Right Answer
- Most small apps — useState + props is enough. 2-3 levels of drilling are tolerable.
- Isolated widgets — calculators, color pickers, anything self-contained. Local state only.
- Simple forms — react-hook-form or useState (think twice before adding a form library).
- With RSC — some server state arrives as server component props naturally (sometimes you don't even need TanStack Query).
Common Pitfalls
- Fresh Context value per render — performance disaster. useMemo to stabilize.
- Everything in Redux — even modal open in the store bloats it. Local stays useState.
- Managing server state in your own store — cache / refetch boilerplate explodes. Lean on TanStack Query.
- Premature optimization — full Redux setup on a small app is over-engineering.
- Mixing store libraries — Zustand + Redux + Jotai simultaneously is cognitive overhead. Pick one or two.
Wrap-up
Big insight in state management = server state ≠ client state. Server state belongs to TanStack Query / SWR. Client state combines useState (local) + Zustand (global) in the modern stack.
Practical: new project default — useState + TanStack Query. Add Zustand when global client state is needed. Legacy / large enterprise — Redux Toolkit. Context is for static values (theme, locale).