State management
The four kinds of state in a React app
"What state library do you use?" is the junior version of the question. The senior version asks what kind of state it is. Four kinds, four lifecycles:
- Local UI state. Modal open, input draft, hover. One component cares.
useState, colocated. Local is the correct default. - Global client state. Session, theme, cross-page selections. The client owns the truth; several distant components read it.
- Server state. Anything fetched. The server owns the truth, and your copy is a cache with an expiry problem.
- URL state. Filters, tabs, pagination, selected id. Belongs in the address bar: shareable, survives reload, back button works.
Most "our state management is a mess" apps have one container holding all four. The mess is misclassification, not the library. ❌ Storing derived data is the same mistake in miniature: fullName in a third useState synced by an effect means an extra render and a drift window. Compute it during render.
Why React context re-renders every consumer
When a provider's value changes (Object.is), every component reading that context re-renders. There are no selectors and no way to subscribe to one field. And the classic self-own:
<AppContext.Provider value={{ user, setUser }}> {/* ❌ new object every render */}
Every render of the provider component creates a fresh object, so all consumers re-render even when user didn't change. Fix ladder:
useMemothe value object.- Split contexts: state in one, dispatch/setters in another.
dispatchfromuseReduceris referentially stable, so dispatch-only consumers never re-render on state changes. - Composition: pass
childreninto the stateful component. Children arrive as an already-built prop, same reference across the provider's re-renders, so non-consumers under a provider don't re-render. ❌ "Context re-renders the whole subtree": only consumers do.
Context is a good fit for low-frequency, read-mostly values (theme, session, locale). A value that changes per keystroke in it is an architecture bug.
Server state is a cache problem
useEffect(() => { dispatch(fetchProducts()); }, []); // ❌ per-mount fetch, hand-rolled flags
This rebuilds a cache badly: no dedup (three components = three requests), no cross-mount memory (navigate back = spinner again or stale data forever), races on fast navigation, and an isLoading/error flag pair copy-pasted per slice. A query cache makes those properties structural:
const { data, isPending, isError } = useQuery({ queryKey: ["products"], queryFn: fetchProducts });
Requests are keyed: mount the same key twice and you get one request and one cache entry. The TanStack Query defaults encode the philosophy, so learn them: cached data is stale immediately (staleTime: 0), stale queries refetch on mount, window focus, and reconnect (stale-while-revalidate: show the cache instantly, revalidate in background), unused entries are garbage-collected after 5 min (gcTime), failures retry 3 times with backoff. ❌ "React Query caches, so why is it refetching?" Refetching stale data on mount, focus, and reconnect is the intended behavior. After a mutation you don't patch state by hand; you invalidateQueries the affected keys and let refetching reconcile.
When a store beats context
External stores (zustand, Redux) offer what context can't: selector subscription.
const count = useCart((s) => s.items.length); // re-renders only when length changes
Consumers pass a selector; the store re-renders them only when the selected slice fails an equality check. The mechanical reason to reach for a store is hot, widely read client state. "The app got big" on its own decides nothing. ❌ "Stores are fast because they live outside React": an unselective subscription re-renders exactly like context; selectors do the work. Store hygiene carries over from the Redux style guide: don't put everything in it, keep state minimal and derive in selectors, store the selected id rather than the selected object, and keep form state local, because dispatching per keystroke is how search boxes get laggy.
How to choose a state management approach
Answer with the questions that decide it. Who owns the truth? (Server-owned means a query cache, full stop.) How many components read it, and how far apart do they sit? How often does it change? (Per keystroke: colocate or use a selector store. Per session: context is fine.) Must it survive reload or be shareable? (Then it lives in the URL.) Run those four and the architecture writes itself, and "we use Context and searchParams and no store at all" becomes a position you can defend as a considered choice. The same framework gives you the migration story: strangle the old store instead of rewriting it. Move one endpoint from the store to the query cache, delete its thunk + slice + flags, let both systems coexist, and only after server state is gone re-measure what "global" state remains. Usually that is session, feature flags, a preference or two. Then pick the smallest container that fits.
Subscription mechanics: useSyncExternalStore
Stores integrate with React through useSyncExternalStore(subscribe, getSnapshot), the primitive that makes external state safe under concurrent rendering. It guarantees a consistent snapshot across one render pass (no tearing, where two components paint different versions of the same store in one frame) and kills the zombie-child/stale-props bug class that plagued hand-rolled subscriptions. On top of it, libraries add selectors + equality, which is the real product: re-render only the components whose slice changed. Context lacks this by design. useContext subscribes to the whole value, and there is no selector API. So "Context vs Redux vs zustand" has a mechanical answer: context distributes low-frequency values; stores exist to subscribe selectively to high-frequency ones. Memoizing harder does not turn one into the other.
Optimistic updates are a rollback protocol
Two shapes, in cost order. Cheap: don't touch the cache. Render mutation.variables with pending styling while isPending, and there is nothing to roll back. Full: write the cache optimistically, which is a protocol, not a setQueryData call:
onMutate: async (next) => {
await queryClient.cancelQueries({ queryKey: ["todos"] }); // an in-flight refetch would clobber the optimistic write
const previous = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old) => applyChange(old, next));
return { previous }; // rollback handle
},
onError: (_e, _v, ctx) => queryClient.setQueryData(["todos"], ctx.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
Each line answers an interview follow-up: skip cancelQueries and a refetch that was already in flight overwrites your optimistic value; skip the snapshot and errors have nothing to restore; skip onSettled invalidation and the cache drifts from the server. ❌ The subtle one: with two concurrent mutations, rolling back to a snapshot can erase the other mutation's optimistic write. Invalidate-on-settled is the reconciliation net, and serious apps queue or disable concurrent mutations on the same entity.
URL state and cache shape
Filters, tabs, pagination, selected item: if a user would want to reload it, share it, or back-button through it, it belongs in the URL. searchParams is the store. localStorage is the wrong answer here (not shareable, no history entries, fights the back button). Bonus: the server can read the URL on first render, which it can never do with client-store state.
Client caches also have a shape decision. Query caches are denormalized per key: the same entity can live in ['todos'] and ['todo', 5], and updates propagate by invalidation or targeted setQueryData per key. Normalized stores (Redux entities, Apollo) keep one record per entity, so a single write updates every view, but you pay with id-joins, cache-update code, and manual garbage collection. Default to denormalized plus invalidation; reach for normalization when many views mutate the same entities and refetch-on-invalidate gets too chatty.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.