Summary
Client state is data that exists only in the browser: UI toggles, form drafts, selection, transient layout. The decision tree is local → lifted → global. useState and useReducer cover most cases; context gates renders to a subtree; external stores (Zustand, Redux Toolkit, Jotai) step in when state fan-out or update frequency outgrows React's own diffing.
Jump to the interview angleClient state is anything the browser owns and the server does not need to persist. It sits on a spectrum by scope: local (one component, useState/useReducer), lifted (siblings share it via a common ancestor), and global/cross-cutting (auth, theme, cart — lives in a store or context). The key rule: server-fetched data is not client state. Data from an API belongs in a cache layer (React Query, SWR, RTK Query), not in useState — putting it there duplicates the truth and creates sync bugs.
Choosing a client-state tool
| Tool | Best fit | Re-render model | Bundle cost | |
|---|---|---|---|---|
| useState / useReducer | Local or lifted state | Owning component only | Zero — built-in | |
| Context + useContext | Rarely-changing subtree value | Every consumer on any reference change | Zero — built-in | |
| Zustand (create) | App-wide UI state, moderate complexity | Selector-scoped — only chosen slice | ~3 kB gzipped | |
| Redux Toolkit (createSlice) | Large team, auditable action log | Selector-scoped via useSelector | ~16 kB gzipped | |
| Jotai (atom) | Scattered feature-local atoms | Per-atom — unused atoms tree-shaken | ~3 kB gzipped |
Scope decision rules
- Start local: useState owns one value; useReducer owns complex shape or multi-step transitions.
- Lift before you globalise: two sibling components sharing state go to their nearest common ancestor.
- Context works for low-frequency shared values (theme, locale); split contexts by update frequency to limit re-renders.
- A Zustand selector (useStore(s => s.count)) re-renders only when count changes — use it over bare useStore().
- Jotai atoms are co-located with features and tree-shaken automatically; prefer it when state is scattered across many features.
Zustand store with typed selectors
A minimal cart store using Zustand's create factory. The selector on the last line means the component re-renders only when totalItems changes — unrelated store mutations are skipped.
import { create } from "zustand";
interface CartState {
items: { id: string; qty: number }[];
addItem: (id: string) => void;
totalItems: () => number;
}
export const useCartStore = create<CartState>()((set, get) => ({
items: [],
addItem: (id) =>
set((s) => {
const existing = s.items.find((i) => i.id === id);
if (existing) {
return {
items: s.items.map((i) =>
i.id === id ? { ...i, qty: i.qty + 1 } : i,
),
};
}
return { items: [...s.items, { id, qty: 1 }] };
}),
totalItems: () => get().items.reduce((sum, i) => sum + i.qty, 0),
}));
// In a component — re-renders only when the total quantity changes
const count = useCartStore((s) => s.totalItems());The selector s => s.totalItems() means this component skips re-renders when unrelated store slices change. No provider needed.
Don't put server data in a client store
Server-fetched data needs background refetch, deduplication, and cache invalidation — none of which useState or a Zustand store provides. Storing API responses in client state creates silent stale-data bugs. Use React Query, SWR, or RTK Query instead. The same rule applies to any value whose source of truth lives on the server.
Interview angle
Interviewers test whether you default to global state or think in scope. Walk through local → lifted → context → store, explain why server state doesn't belong in a client store, and name the re-render problem with context. If asked to compare tools, lead with the selector story.
Soundbite: "Start local, lift when siblings need it, add a store only when context's re-render cost becomes the bottleneck."
Key terms
- useState
- React hook for a single local state value; re-renders the owning component on change.
- useReducer
- React hook managing state via a pure `(state, action) => state` function.
- Context
- React mechanism to share a value to a subtree without prop-drilling; re-renders all consumers on change.
- Zustand `create`
- Factory that returns a typed store hook; selectors limit which components re-render.
- Jotai `atom`
- Smallest unit of Jotai state; atoms compose via derived atoms and are tree-shakeable.
- createSlice
- RTK function that generates action creators and a reducer from a single object.