State Management At Scale
The argument about which state library to use dissolves once state is sorted by who owns it: a component, a feature, the server, the URL, a form, or storage. This part is the six kinds with their owners and defaults, context as dependency injection and why it is not a store, the stores compared by what re-renders, server state as a cache problem, and a decision procedure for any piece of state.
Six kinds of state, and who owns each
"Redux, Zustand, Jotai, Context, TanStack Query, XState, or just useState. The team argues every quarter. What is the actual decision?"
The decision is per kind of state, not per app. State has an owner and a lifetime, and there are about six kinds: local UI state (one component), shared UI state (a feature), server state (the server, cached on the client), URL state (the address bar), form state (one form), and session or device state (storage). Each has a right default and a characteristic mistake. The libraries are tools for one or two kinds each; the argument dissolves once the kinds are named.
// colocation: put state in the lowest component that needs it, and move it only when a second one does
function Comments({ postId }) {
const [draft, setDraft] = useState('') // only the editor needs it → here, not in the page
const [sort, setSort] = useState('newest') // the list and the sort control both need it → here, their common parent
return <>
<SortControl value={sort} onChange={setSort} />
<CommentList postId={postId} sort={sort} />
<Editor value={draft} onChange={setDraft} />
</>
}
// the cost of state too high: every setDraft re-renders the page; every page child without memo renders. the fix is moving it down, not memo.
// the cost of state too low: two siblings need it → lift to the parent (one level); three features across routes need it → a store, not a 6-level lift.
// derive, do not store: anything computable from other state is a variable, not a useState
const total = items.reduce((s, i) => s + i.price, 0) // not: const [total, setTotal] = useState(); useEffect(() => setTotal(…), [items])
const isValid = errors.length === 0 // storing it creates two sources of truth and an effect to sync them (part 11)- Local UI:
useStatein the component that renders it. Move it only when a second component needs it. - Shared UI: lift to the lowest common ancestor. If that ancestor is far up, re-renders too much, or the sharing crosses routes: a store with selectors.
- Server: a query cache. Never a reducer with loading flags; never
useEffect+useStateper component (part 8 has why). - URL: the router. Filters, pagination, selection that should be shareable or survive refresh.
- Form: the form: uncontrolled inputs read at submit, a form library for large or dynamic forms,
useActionStatewith actions (part 9). - Session: storage, read through
useSyncExternalStoreor at bootstrap; a cookie for anything the server needs.
- Colocate: state lives as low as possible. The cost of state too high is re-renders of everything under it on every change; the cost too low is prop threading, which is cheaper and more visible.
- Derive, do not store: anything computable from other state is computed during render (or memoised), never stored and synced with an effect. Two sources of truth plus a sync is the root of the effect-chain anti-pattern (part 11).
Context: dependency injection, not a store
Context solves "how does a deep component get a value without threading it through ten props". It does not solve "how do many components share changing state efficiently", because a context has no selector: every consumer renders on every change of the provider's value (part 2's propagation walk). Used for the first problem it is the right tool; used for the second it is the most common performance regression in React codebases.
- Dependency injection: the API client, the router, the theme object, the i18n instance, feature flags, the current user. Values that change rarely (or never) and are read widely.
- Compound components (part 10): a
<Tabs>providing the active tab to<Tab>children. The scope is small, so "every consumer renders" is a handful of elements. - Scoping a store: providing a store instance (a Zustand store, a Jotai Provider, a Redux store) so that subtrees can have their own; the context carries the stable store reference, and subscriptions handle the changes.
- Frequently changing values: a form's values or a cursor position in context renders every consumer on every change. Measure: the Profiler's "context changed" reason on components that did not need the change.
- A value rebuilt per render:
value={{ user, setUser }}is a new object every provider render, so consumers render even whenuserdid not change.useMemothe value (or the Compiler does). - One big context:
{ user, cart, ui, settings }couples unrelated consumers. Split by change frequency: a context per slice, or move the changing slices to a store. - Context as the only state tool: it has no devtools, no middleware, no persistence, no selectors. Past a few slices, a store is less code.
- Split the value and the setter into two contexts (setters are stable; consumers that only dispatch never re-render).
use-context-selectoror a hand-rolled store in context: the context carries a store object withsubscribeandgetSnapshot; components useuseSyncExternalStorewith a selector. That is a store; call it one.
Stores: Redux, Zustand, Jotai, XState
// a Zustand store, shaped for selectors
const useCart = create((set, get) => ({
items: [],
add: item => set(s => ({ items: [...s.items, item] })), // immutable update → new reference → subscribers' selectors re-run
remove: id => set(s => ({ items: s.items.filter(i => i.id !== id) })),
total: () => get().items.reduce((t, i) => t + i.price, 0), // a method, not stored state: derive on read
}))
function CartBadge() { const n = useCart(s => s.items.length); return <span>{n}</span> } // renders only when the length changes
function Total() { const total = useCart(s => s.total()); return <b>{total}</b> } // a number: Object.is stable when equal
function Lines() { const items = useCart(s => s.items); return items.map(i => <Line key={i.id} item={i} />) } // the array reference: changes on add/remove only
// the same with Redux Toolkit: createSlice + useSelector + createSelector for derived objects; more structure, more boilerplate, better devtools
// the same with Jotai: const itemsAtom = atom([]); const countAtom = atom(get => get(itemsAtom).length); useAtomValue(countAtom)
// when to pick which: Zustand for "a store, quickly"; RTK when a team needs the conventions and the devtools; Jotai when state is a graph of
// small derived pieces; XState when the thing is a workflow with states and transitions (a checkout, an upload, a wizard)- A store outside React holding the state; subscriptions via
useSyncExternalStore(part 5); selectors asgetSnapshot; a re-render only when the selected value's reference changes. The differences are in how state is structured and updated, not in how React is notified. - Immutability is the contract: an update produces a new reference for the changed path (and, with structural sharing, the same references elsewhere) so that selectors and bailouts can compare by identity. Immer (in RTK, optional in Zustand) lets you write mutations that produce immutable results.
- Selectors must be stable for unchanged state: returning a fresh object each call (
s => ({ a: s.a })) re-renders every time; derive with a memoised selector (reselect,useShallow) or select primitives.
- Redux Toolkit: slices, reducers, actions, middleware (thunks, listeners), the time-travel devtools, RTK Query for server state. The most structure; the best tooling; the most boilerplate. Right for large teams that want one documented way, and for state that benefits from an action log (undo, audit, replay).
- Zustand: a store is a hook; actions are functions on the store; middleware for persistence, devtools, immer. Minimal; the default choice for "we need a store" in most apps.
- Jotai: atoms as the unit; derived atoms form a graph; components subscribe to atoms. Right when state is many small, interdependent pieces (editors, complex forms, dashboards) and you want the dependency graph to do the selection.
- XState: statecharts: explicit states, transitions, guards, nested and parallel states, actors. Right when the thing being modelled is a process (checkout, upload, auth flow, a game) where "which transitions are legal now" is the hard part. Overkill for a toggle.
- Valtio, MobX: proxy-based mutable stores (the JS course part 13's reactivity): write mutations, get fine-grained tracking. Right for teams that prefer the mutable model and accept the Proxy cost at the edges.
- None:
useStateand lifting, for the many apps whose shared UI state is a handful of values. Add a store when lifting crosses three levels or two routes, not before.
Server state is a cache
// server state belongs in a query cache, not in a reducer
const { data: post, isPending, error } = useQuery({ queryKey: ['post', id], queryFn: () => api.posts.get(id), staleTime: 60_000 })
// what the cache does that your reducer did not: dedupe concurrent requests for the same key; keep the data after unmount (gcTime);
// refetch on window focus / reconnect / interval; mark stale after staleTime and show stale-while-revalidating; retry with backoff;
// structural sharing so unchanged parts keep their references (bailouts hold); pagination and infinite queries; optimistic updates with rollback
const like = useMutation({ mutationFn: api.posts.like, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['post', id] }) })
// with RSC: the server component IS the query; revalidatePath / revalidateTag is the invalidation; the client cache is the router's payload cache.
// the categories are the same; the location moved. part 8 has the fetching patterns- You do not own it. The server can change it under you; another tab can; the user's other device can. Any client copy is a cache with a staleness problem, and the questions are cache questions: when to refetch, how to dedupe, what to show while stale, how to invalidate after a mutation, how to roll back an optimistic update.
- A reducer answers none of those. The classic Redux pattern (FETCH_START / SUCCESS / FAILURE per entity, with
loadinganderrorflags in state) reimplements a worse cache per entity: no deduplication, no background refresh, manual invalidation, data kept forever or lost on unmount. - Per-component
useEffectfetching is the same without even the sharing: two components needing the same data fetch twice; unmount and remount refetch; race conditions on fast navigation (part 8).
- Keys: a query is identified by a key (
['post', id]); any component using the key shares the entry. - Deduplication, caching, garbage collection: one request per key in flight; data kept after unmount for
gcTime; a remount reads the cache and refetches in the background if stale. - Staleness:
staleTimedecides when a cached entry triggers a background refetch; stale data is shown meanwhile (stale-while-revalidate). - Invalidation and mutations:
useMutationwithonSuccess: invalidateQueriesre-fetches what the mutation affected; optimistic updates edit the cache before the response and roll back on error. - Structural sharing: a refetch that returns equal data keeps the old references, so bailouts (part 2) hold.
- Suspense and streaming integration; prefetching on hover; persistence to storage for offline.
- The server component is the query; the framework's cache (per fetch, per route, with tags) is the cache;
revalidatePathandrevalidateTagare invalidation; the router's payload cache is the client copy. Same categories, moved server-side; client query caches remain for client-fetched, user-specific, or real-time data.
URL, forms, session, and the decision procedure
- What: route, params, search (
?q=&page=&sort=), hash. Anything the user would expect to share, bookmark, or get back with the back button. - How: read from the router (
useParams,useSearchParams); write by navigating; keep a typed parser for search params (nuqs, zod schemas) so the URL is the source of truth with validation. - Not: duplicated into a store. A store copy desynchronises on navigation; a
useEffectsyncing them is an effect chain.
- What: field values, touched and dirty flags, validation errors, submission status, for one form.
- How: uncontrolled inputs read at submit for simple forms; a form library (React Hook Form, TanStack Form) for large or dynamic ones (per-field subscriptions so one keystroke renders one field);
useActionStateanduseFormStatuswith actions for server-backed forms. Part 9. - Not: every keystroke into a global store or context.
- What: auth, theme, locale, feature flags, drafts to survive reload, device capabilities.
- How: cookies for anything the server needs on the first request (auth, theme for SSR); localStorage or IndexedDB for client-only persistence, read through
useSyncExternalStorewith a server snapshot (so SSR and hydration agree) or a store's persist middleware. - Not: read during render directly (hydration mismatch, tearing); module-level variables (not subscribable).
- Who owns it? The server → a query cache. The URL → the router. Storage → uSES. Otherwise, React.
- Who reads it? One component →
useStatethere. Siblings → lift once. Far-apart or cross-route → a store. - Is it derivable? Then it is not state; compute it.
- How often does it change? Rarely → context is fine. Often → selectors (a store), never context.
- Is it a process with legal transitions? A state machine.
- Measure the re-renders (the Profiler's "why") after placing it; move it if the count surprises you.