Topics
v5.0.0
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.

LibraryOwns
React Query / SWRServer/async state — fetch, cache, stale, invalidate
ZustandMinimal global client store — selectors, low boilerplate
JotaiAtomic client state — fine-grained derived atoms
Redux ToolkitPredictable global state — devtools, middleware, enterprise
ContextCold 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 steps

Default 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 useState
const 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 required

Basic 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 cleanup

Prefer 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 hood

Context 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 reads

PR 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 flags

Best mental model

Remote truth → async, stale, invalidate UI ephemeral → sync, local, no network Selector → "subscribe to this slice only" One fact → one owner, one tool

Practical 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

Last updated on