On this page · sections
State Management Libraries
Pick state tools by state type, not team preference. React Query owns remote data; Zustand/Jotai own client UI; Redux when org mandates audit and middleware. Zustand selectors subscribe to a slice — bare useStore() re-renders on every key change.
| Library | Owns |
|---|---|
| React Query / SWR | Server/async state — fetch, cache, stale, invalidate |
| Zustand | Minimal global client store — selectors, low boilerplate |
| Jotai | Atomic client state — fine-grained derived atoms |
| Redux Toolkit | Predictable global state — devtools, middleware, enterprise |
| Context | Cold config only — theme, locale; not hot globals |
State taxonomy
Definition
'use client';
// React Query — server state
function Profile() {
const { data } = useQuery({ queryKey: ['me'], queryFn: fetchMe });
return <span>{data?.name}</span>;
}
// Zustand — client UI
const useSidebar = create<{ open: boolean; toggle: () => void }>((set) => ({
open: true,
toggle: () => set((s) => ({ open: !s.open })),
}));Server cache → React Query / SWR / RSC fetch
UI shell → Zustand
Form wizard → Jotai atoms
Enterprise audit → Redux RTK + RTK Query
XState → orthogonal — state machines for complex flows; Query owns server stepsDefault greenfield: Query for API, Zustand or Jotai for UI globals, Redux only with org mandate.
Interview: One library for the whole app is wrong. Split by ownership: remote truth vs UI ephemeral vs URL-shareable filters.
Zustand vs Jotai
Definition
Zustand → One store object; subscribe via selector; feels Redux-lite
Jotai → Atoms as minimal units; dependency graph like spread useStateconst useCartUi = create<{ drawerOpen: boolean; open: () => void }>((set) => ({
drawerOpen: false,
open: () => set({ drawerOpen: true }),
}));
function ZustandDrawerBadge() {
const open = useCartUi((s) => s.drawerOpen);
return <span>{open ? 'Open' : 'Closed'}</span>;
}
const countAtom = atom(0);
const doubleAtom = atom((get) => get(countAtom) * 2);
function JotaiDouble() {
const double = useAtomValue(doubleAtom);
return <span>{double}</span>;
}Zustand wins for a simple global bag with imperative actions. Jotai wins for bottom-up composition and derived async atoms.
Zustand mistake → useStore() without selector → re-renders on any key
Jotai default → per-atom subscription; no Provider requiredBasic selector subscription
Definition
'use client';
import { create } from 'zustand';
type Store = {
count: number;
theme: 'light' | 'dark';
inc: () => void;
setTheme: (t: 'light' | 'dark') => void;
};
const useApp = create<Store>((set) => ({
count: 0,
theme: 'light',
inc: () => set((s) => ({ count: s.count + 1 })),
setTheme: (theme) => set({ theme }),
}));
function Counter() {
const count = useApp((s) => s.count);
const inc = useApp((s) => s.inc);
return <button onClick={inc}>{count}</button>;
}
function ThemeToggle() {
const theme = useApp((s) => s.theme);
const setTheme = useApp((s) => s.setTheme);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme}
</button>
);
}When setTheme runs: Counter selector returns same count → skip. ThemeToggle returns new theme → render. Actions are stable refs — safe to select s => s.inc.
Interview: Always select minimum fields. Bare
useStore()subscribes to the entire store.
Shallow compare and derived picks
Definition
Inline object in selector creates a new reference every run — Object.is fails even when values unchanged.
import { useShallow } from 'zustand/react/shallow';
function WinLossGood() {
const { wins, losses } = useStats(
useShallow((s) => ({ wins: s.wins, losses: s.losses })),
);
return <span>{wins}-{losses}</span>;
}
function OpenCount() {
const openCount = useTodos((s) => s.items.filter((i) => !i.done).length);
return <span>{openCount} open</span>;
}Fixes: select primitives separately, useShallow, or custom equality. For hot dashboards, denormalized counts updated in applyTick beat re-filtering 10k items on every unrelated store change.
Actions outside React
Definition
import { subscribeWithSelector } from 'zustand/middleware';
const useSession = create(
subscribeWithSelector<{ token: string | null; setToken: (t: string | null) => void }>(
(set) => ({ token: null, setToken: (token) => set({ token }) }),
),
);
useSession.subscribe(
(s) => s.token,
(token) => { if (!token) redirectToLogin(); },
);
export function getAuthHeader() {
const token = useSession.getState().token;
return token ? { Authorization: `Bearer ${token}` } : {};
}getState() → sync read, no subscription
setState() → imperative partial update
subscribeWithSelector → filter external listeners by selector
Do not subscribe in render → use useEffect with cleanupPrefer getState().applyTick in WebSocket handlers over React setState — avoids stale closures.
Redux Toolkit in 2025
Definition
Choose Redux when org standardizes on Redux (training, lint, middleware), RTK Query integrates with normalized entity cache, or regulated domains need action audit logs.
const uiSlice = createSlice({
name: 'ui',
initialState: { theme: 'light' as 'light' | 'dark' },
reducers: {
toggleTheme: (state) => {
state.theme = state.theme === 'light' ? 'dark' : 'light';
},
},
});
function ThemeLabel() {
const theme = useSelector((s: { ui: { theme: string } }) => s.ui.theme);
return <span>{theme}</span>;
}Brownfield Redux stays Redux. Greenfield with three engineers rarely needs the ceremony unless compliance demands it.
Interview: “Redux required for scale” is wrong — Query + Zustand scales for most products. Redux wins on organizational requirements.
React Query is not client state
Definition
// ❌ Anti-pattern — sidebar in Query cache
function BadSidebar() {
const qc = useQueryClient();
const { data: open = false } = useQuery({
queryKey: ['ui', 'sidebar'],
queryFn: async () => false,
staleTime: Infinity,
});
return <button onClick={() => qc.setQueryData(['ui', 'sidebar'], !open)}>Toggle</button>;
}
// ✅ Client store for UI toggles
const useSidebar = create<{ open: boolean; toggle: () => void }>((set) => ({
open: false,
toggle: () => set((s) => ({ open: !s.open })),
}));Query caches keyed async data — wrong semantics for synchronous UI toggles. gcTime evicts inactive queries awkwardly for UI flags. “React Query replaced Redux” is half true — server state half only.
Zustand vs Context
Definition
const AppCtx = createContext<{ count: number; label: string } | null>(null);
const ContextConsumer = memo(function ContextConsumer() {
const ctx = useContext(AppCtx)!;
return <span>{ctx.label}</span>; // still re-renders when count changes
});
function ZustandLabel() {
const label = useLabelStore((s) => s.label);
return <span>{label}</span>; // skip when only count changes
}Context → provider value changes → ALL consumers re-render; memo does not help
Zustand → external store + per-selector subscriptions; useSyncExternalStore under hoodContext is fine for rare updates: theme once per session, locale, auth provider shell. Not cart count, live filters, or audio position. When update frequency exceeds ~1/sec or subscribers exceed a dozen → Zustand with selectors or Jotai atoms.
Monorepo stack design
Definition
React Query → all HTTP; shared QueryClient provider
Query key factory → @app/query-keys package
Zustand → cross-route UI (sidebar, feature flags display)
URL → shareable filters
RSC → first choice for readsPR rejections: copying /users response into Zustand on fetch; new Redux slice for modal toggle; fetch in useEffect without Query when RSC could own the read.
Interview: Each team folder owns feature-scoped stores — not a global god object.
Senior Interview Summary
What to say
Layer by state type: Query for HTTP, Zustand for UI globals, URL for shareable filters, RSC for reads. Zustand selectors subscribe to a slice — Object.is on selector output. Bare useStore re-renders on everything. useShallow fixes object selections. Context broadcasts to all consumers; Zustand pub/sub per selector.
Library map
HTTP data → React Query (or RSC fetch)
UI globals → Zustand or Jotai
URL filters → searchParams
Enterprise → Redux only with justification
Context → cold config only
Query for UI → wrong tool; gcTime evicts UI flagsBest mental model
Remote truth → async, stale, invalidate
UI ephemeral → sync, local, no network
Selector → "subscribe to this slice only"
One fact → one owner, one toolPractical rule
Shared query-keys → reviewed key factories prevent typo stale cache
Ban → fetch in useEffect without Query; API mirror in Zustand
CI codemod → flag bare useStore() and object selectors without useShallow
ADR → required for new global stores