Guide
Frontend state management explained
Every interactive web app holds state — the data that drives what users see and what happens when they click, type, or scroll. State management is the discipline of deciding where that data lives, who can read or change it, and how updates propagate through the UI without bugs or performance cliffs. Modern frontends rarely use one pattern for everything. Instead, teams split state into layers: local UI state (a modal open or closed), shared client state (theme, cart, auth session), server state (API responses that can go stale), and sometimes URL state (filters and tabs users expect to bookmark). This guide walks through each layer, the tools React ecosystems offer, and a practical decision framework so you reach for the smallest solution that actually solves the problem.
The four kinds of frontend state
Before choosing Redux, Zustand, or anything else, classify what you are storing. Mixing categories in one global store is how teams end up with 40 reducers and no one knows which slice owns the checkout flow.
Local UI state
Data that belongs to a single component or a small subtree: form field values before
submit, whether a dropdown is expanded, hover highlights, animation progress. If no
sibling needs it and it does not survive a route change, keep it local with
useState or useReducer. Lifting state up one level is cheaper
than introducing a global store.
Shared client state
Data multiple distant components need without a server round-trip: dark mode, sidebar collapsed, shopping cart contents, wallet connection status in a dApp. This is where Context, Zustand, Redux, or Jotai earn their place — but only when prop drilling becomes painful or you need predictable update ordering across features.
Server state (async cache)
Data that originates on a backend and can become stale: user profiles, product lists, blockchain balances, paginated search results. Treating server responses like ordinary in-memory variables leads to duplicate fetches, race conditions, and UI that shows yesterday's data after a mutation. Libraries like TanStack Query (React Query) or SWR exist specifically for caching, background refetch, and optimistic updates — they are not replacements for Redux; they solve a different problem.
URL and persisted state
Filters, sort order, selected tab, and shareable views belong in the URL query string or
path when users expect to bookmark or share them. Long-lived preferences (theme, locale)
may live in localStorage via
browser storage APIs.
Do not duplicate URL state in a global store unless you have a sync strategy.
React built-ins: useState, useReducer, and Context
React fundamentals
start here. useState handles simple values; useReducer fits
when the next state depends on the previous state through multiple action types — wizards,
complex forms, or state machines with explicit transitions.
When Context is enough
React Context passes a value down the tree without prop drilling. It works well for
low-churn data: theme, locale, authenticated user identity, feature flags. Pair it with
useMemo on the provider value object so consumers do not re-render on every
parent render.
Context pitfalls
Context is not a performance-optimized store. Any consumer re-renders when the context value changes — even if it only cares about one field. Putting frequently updating data (live chat messages, scroll position, animation frames) in Context will tank Interaction to Next Paint (INP). Split contexts by update frequency, or move hot data to a selector-based library. Context also lacks built-in devtools time-travel, middleware, or async orchestration — fine for app shell concerns, wrong for a full data layer.
Global client stores: Redux Toolkit and Zustand
Redux Toolkit (RTK)
Redux popularized predictable state updates through pure reducers and a single store.
Redux Toolkit cuts boilerplate with createSlice,
Immer-powered mutable-looking updates, and RTK Query for API caching (overlapping with
TanStack Query — pick one caching layer, not both for the same endpoints).
RTK shines in large teams that need strict conventions: every change is an action, reducers are easy to unit test, Redux DevTools replay production bugs, and middleware handles logging, analytics, and cross-slice side effects. The cost is ceremony — slices, selectors, typed hooks — that feels heavy for a dashboard with three screens.
Zustand
Zustand offers a minimal API: create a store with set and
get, subscribe with hooks, optionally use selectors so components re-render
only when their slice changes. No providers wrapping the tree. Most greenfield React apps
that need shared client state in 2026 choose Zustand or Jotai over classic Redux unless
compliance or team history mandates RTK.
Zustand stores are plain JavaScript objects — easy to call from outside React (event
handlers, WebSocket callbacks in
real-time apps).
Use subscribeWithSelector or shallow compare helpers to avoid unnecessary
renders when the store grows.
Atomic state (Jotai, Recoil)
Jotai models state as atoms composed together — derived atoms recompute when dependencies change, similar to spreadsheet cells. Good for highly interconnected UI (design tools, node editors) where a global normalized store fights the domain model. Recoil pioneered this in Facebook's ecosystem; Jotai is the lighter maintained alternative for React today.
Server state with TanStack Query
Fetch-on-mount in useEffect duplicates requests across components, ignores
stale-while-revalidate patterns, and makes loading/error handling inconsistent.
TanStack Query treats each query key (e.g. ['user', id]) as
a cache entry with lifecycle: loading, success, error, stale, refetching.
Key behaviors teams rely on:
- Deduplication — ten components mounting the same query trigger one network call.
- Background refetch — show cached data instantly, refresh when the user refocuses the tab.
- Mutations with invalidation — after POST /cart, invalidate
['cart']so lists update. - Optimistic updates — update UI before the server confirms, roll back on failure.
- Pagination and infinite scroll —
useInfiniteQuerymerges pages without manual merge logic.
Pair TanStack Query for server data with Zustand or local state for UI-only concerns. Do not stuff API responses into Redux unless you are already all-in on RTK Query with a reason to standardize there.
Decision framework: what to use when
| Scenario | Recommended approach |
|---|---|
| Single component toggle or input | useState / useReducer |
| Theme, locale, auth shell | Context or Zustand slice |
| REST/GraphQL lists and detail views | TanStack Query or SWR |
| Multi-step wizard with shared draft | useReducer in parent, or Zustand |
| Large app, many teams, audit trail | Redux Toolkit + strict slice ownership |
| Shareable filters and tabs | URL search params (Next.js useSearchParams, React Router) |
| Offline-first or heavy client DB | IndexedDB + sync layer (Dexie, RxDB) — not Redux alone |
Start with the simplest option that works. Add a global store when you feel pain — duplicate fetches, inconsistent UI, or prop chains through five layers — not because a tutorial said every React app needs Redux.
Performance and correctness habits
Selectors and referential stability
Global stores re-render subscribers when selected data changes. Derive computed values in selectors (Reselect with RTK, Zustand selector functions) instead of computing in render and passing new object references every time. Memoize list children with stable keys — the same guidance as in React list rendering.
Avoid storing derived data
If fullName is always firstName + lastName, derive it at read
time. Duplicated derived fields drift when one source updates and the other does not.
Batch updates
React 18 batches state updates in event handlers and many async paths automatically.
Multiple setState calls in one tick produce one render. Outside React's batch
(some third-party callbacks), wrap updates in flushSync only when you truly
need synchronous DOM reads — rare.
TypeScript at the boundaries
Typed hooks (useAppSelector, Zustand generics) catch shape mismatches at
compile time.
TypeScript fundamentals
pay off most where state crosses module boundaries — API DTOs into store slices, form
payloads into mutations.
Testing
Reducers and pure selector functions unit-test without a DOM. Integration tests render with a real QueryClient provider (retry disabled) and assert loading → success flows. Reset stores between tests — leaked global state causes order-dependent failures.
Common mistakes
- Global store for everything. Modal open state in Redux is ceremony without benefit.
- Caching API data manually. A
useEffectplus Context cache lacks invalidation, dedup, and stale policies TanStack Query provides free. - Two sources of truth. Cart in Zustand and cart endpoint refetched independently — pick one writer, invalidate the other.
- Mutating state directly. RTK uses Immer; plain Zustand and React state require immutable updates or subtle bugs appear.
- Ignoring the URL. Users refresh and lose filter state because it lived only in memory.
- Over-subscribing. Selecting the entire store object forces re-render on any change — select fields narrowly.
Key takeaways
- Split state into local UI, shared client, server cache, and URL/persisted layers — different tools fit each.
useState,useReducer, and Context cover most apps until shared updates or performance force a store.- Zustand is the pragmatic default for client global state; Redux Toolkit when conventions and DevTools matter at scale.
- TanStack Query (or SWR) owns async server data — not your Redux slice.
- Prefer selectors, derivation, and narrow subscriptions over storing duplicates and re-rendering the tree.
Related reading
- React fundamentals — components, hooks, and when to lift state
- JavaScript event loop — how async updates interact with rendering
- TypeScript fundamentals — typing stores, actions, and API responses
- SSR vs CSR explained — hydrating server state without mismatch bugs