Part 7 · 5 chapters · ~35 min

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.

34

Six kinds of state, and who owns each

the question

"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.

code
// 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)
the defaults
  1. Local UI: useState in the component that renders it. Move it only when a second component needs it.
  2. 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.
  3. Server: a query cache. Never a reducer with loading flags; never useEffect + useState per component (part 8 has why).
  4. URL: the router. Filters, pagination, selection that should be shareable or survive refresh.
  5. Form: the form: uncontrolled inputs read at submit, a form library for large or dynamic forms, useActionState with actions (part 9).
  6. Session: storage, read through useSyncExternalStore or at bootstrap; a cookie for anything the server needs.
the two rules under all of them
  1. 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.
  2. 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).
SIX KINDS OF STATE
sorted by who owns it and how long it lives
swipe the figure sideways, or tap expand for full screen
1/6
local UI
Local UI state: is this dropdown open, which tab is selected, the hover index. Owned by one component; dies with it. useState. The majority of state in any app, and the kind that should never leave the component that renders it.
35

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.

what context is good for
  1. 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.
  2. 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.
  3. 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.
what goes wrong
  1. 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.
  2. A value rebuilt per render: value={{ user, setUser }} is a new object every provider render, so consumers render even when user did not change. useMemo the value (or the Compiler does).
  3. 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.
  4. 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.
the selector fix, if you must
  1. Split the value and the setter into two contexts (setters are stable; consumers that only dispatch never re-render).
  2. use-context-selector or a hand-rolled store in context: the context carries a store object with subscribe and getSnapshot; components use useSyncExternalStore with a selector. That is a store; call it one.
36

Stores: Redux, Zustand, Jotai, XState

code
// 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)
the shared mechanism
  1. A store outside React holding the state; subscriptions via useSyncExternalStore (part 5); selectors as getSnapshot; 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.
  2. 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.
  3. 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.
choosing
  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. None: useState and 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.
HOW EACH STORE RE-RENDERS
context, Redux, Zustand, Jotai, XState: who renders on a change
swipe the figure sideways, or tap expand for full screen
1/6
the update
The state: { user, cart: { items, total }, ui: { theme, sidebar } }. Five components: Header (user, theme), Cart (cart), CartBadge (cart.items.length), Sidebar (ui.sidebar), Settings (theme). An update: cart.items gets one more item.
37

Server state is a cache

code
// 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
why it is a separate category
  1. 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.
  2. A reducer answers none of those. The classic Redux pattern (FETCH_START / SUCCESS / FAILURE per entity, with loading and error flags in state) reimplements a worse cache per entity: no deduplication, no background refresh, manual invalidation, data kept forever or lost on unmount.
  3. Per-component useEffect fetching 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).
what a query cache gives you
  1. Keys: a query is identified by a key (['post', id]); any component using the key shares the entry.
  2. 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.
  3. Staleness: staleTime decides when a cached entry triggers a background refetch; stale data is shown meanwhile (stale-while-revalidate).
  4. Invalidation and mutations: useMutation with onSuccess: invalidateQueries re-fetches what the mutation affected; optimistic updates edit the cache before the response and roll back on error.
  5. Structural sharing: a refetch that returns equal data keeps the old references, so bailouts (part 2) hold.
  6. Suspense and streaming integration; prefetching on hover; persistence to storage for offline.
with Server Components
  1. The server component is the query; the framework's cache (per fetch, per route, with tags) is the cache; revalidatePath and revalidateTag are 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.
38

URL, forms, session, and the decision procedure

URL state
  1. What: route, params, search (?q=&page=&sort=), hash. Anything the user would expect to share, bookmark, or get back with the back button.
  2. 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.
  3. Not: duplicated into a store. A store copy desynchronises on navigation; a useEffect syncing them is an effect chain.
form state
  1. What: field values, touched and dirty flags, validation errors, submission status, for one form.
  2. 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); useActionState and useFormStatus with actions for server-backed forms. Part 9.
  3. Not: every keystroke into a global store or context.
session and device state
  1. What: auth, theme, locale, feature flags, drafts to survive reload, device capabilities.
  2. How: cookies for anything the server needs on the first request (auth, theme for SSR); localStorage or IndexedDB for client-only persistence, read through useSyncExternalStore with a server snapshot (so SSR and hydration agree) or a store's persist middleware.
  3. Not: read during render directly (hydration mismatch, tearing); module-level variables (not subscribable).
the procedure, for any piece of state
  1. Who owns it? The server → a query cache. The URL → the router. Storage → uSES. Otherwise, React.
  2. Who reads it? One component → useState there. Siblings → lift once. Far-apart or cross-route → a store.
  3. Is it derivable? Then it is not state; compute it.
  4. How often does it change? Rarely → context is fine. Often → selectors (a store), never context.
  5. Is it a process with legal transitions? A state machine.
  6. Measure the re-renders (the Profiler's "why") after placing it; move it if the count surprises you.
the pointer
Part 8 is the data-fetching patterns that the server-state category implies: fetch-on-render and its waterfalls, render-as-you-fetch, invalidation, optimistic updates. The cache is the tool; the next part is how to drive it.