Guide

Frontend state management explained

Every interactive web app is a function from state to UI. The hard part is not storing a boolean in useState — it is deciding which data lives where, how updates propagate through the tree, and when a global store earns its complexity tax. Teams that dump everything into Redux on day one ship slowly; teams that never lift state out of deeply nested props pass callback chains through twelve components. This guide sorts frontend state into four categories, maps each to the right tool (from local hooks to React Context, Zustand, Redux Toolkit, and TanStack Query), and explains the re-render and caching mistakes that make otherwise solid apps feel sluggish.

Four categories of frontend state

Before picking a library, classify what you are storing. Most production bugs come from using the wrong category — caching server JSON in a global reducer, or putting ephemeral UI toggles in URL query params.

  • Local (component) state — data owned by one component and irrelevant elsewhere: whether a dropdown is open, the current value of a text field before submit, hover highlights. Keep it in useState or useReducer at the lowest component that needs it.
  • Shared client state — data multiple distant components must read or write: theme (dark/light), shopping cart contents, wallet connection status, feature-flag overrides. This is where Context, Zustand, Jotai, or Redux enter — but only after colocated state fails.
  • Server (async) state — data that originates on a backend and has its own lifecycle: loading, stale, error, refetch. User profiles, product catalogs, paginated lists. Treat it differently from client state; libraries like TanStack Query, SWR, and RTK Query exist because hand-rolling fetch + cache + invalidation in useEffect does not scale.
  • URL state — data that should be shareable, bookmarkable, or deep-linkable: search filters, pagination cursors, selected tab, map coordinates. The address bar is a persistent, serializable store users already understand — use it before inventing global stores for navigation-shaped data.

A dashboard might combine all four: local form field state, a Zustand slice for sidebar collapse, TanStack Query for API records, and URL params for ?page=3&sort=price. The art is keeping boundaries crisp so each layer does one job well.

Start with colocation — lift state only when forced

The default in React is colocation: state lives next to the components that use it. Pass data down via props; pass callbacks up for updates. This is boring and that is the point — you can read a subtree top-to-bottom without hunting imports across the repo.

Lift state when two siblings need the same value, or when a parent must orchestrate children. Common signals you have lifted enough:

  • Prop drilling hurts readability — five intermediate components pass userId they never use, only to reach a leaf.
  • Updates must bypass the tree — a toast system, modal portal, or analytics hook needs to fire from anywhere without threading callbacks.
  • Persistence across routes — cart survives navigation without refetching from the server on every page change.

If only one subtree cares, Context scoped to that subtree beats a global store. Wrap the checkout flow in CheckoutProvider, not the entire app in AppProvider with forty fields most pages ignore.

React Context — convenient, easy to misuse

Context solves prop drilling by letting descendants subscribe to a value without intermediate props. It is built into React and needs no dependencies — ideal for theme, locale, or auth session that changes rarely.

The trap: any Context value change re-renders every consumer, unless you split contexts or memoize aggressively. Putting a large object { user, cart, notifications, flags } in one provider means updating the cart re-renders components that only read user. Fixes:

  • Split into multiple contexts by update frequency and concern.
  • Store stable dispatch functions separately from frequently changing data.
  • Combine with useMemo on the provider value — but prefer structural splits over memo band-aids.
  • For high-frequency updates (mouse position, audio levels), Context is usually wrong — use refs, external stores, or canvas-local state instead.

Context is not a state management framework: it has no built-in middleware, devtools, time-travel, or selector subscriptions. Once you need those, graduate to a dedicated client store.

Global client stores: Zustand vs Redux Toolkit

When shared client state grows beyond two Context providers, teams reach for a global store. The two most common choices in 2026:

Zustand — minimal API, hook-first

Zustand creates a store outside React; components subscribe via hooks with optional selectors. No providers required, small bundle, straightforward TypeScript inference. Good for medium complexity: UI chrome, wizard steps, client-only preferences, game HUD state.

const useCart = create((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
}));
// Selector — re-render only when item count changes
const count = useCart((s) => s.items.length);

Redux Toolkit — conventions for large teams

Redux Toolkit (RTK) adds opinionated slices, Immer-powered reducers, and excellent DevTools. It shines when many engineers touch the same store, you need predictable action logs for debugging, or you already use RTK Query for server state in one ecosystem. The cost is ceremony: actions, reducers, selectors, and boilerplate that Zustand avoids.

Rule of thumb: Zustand for product teams shipping fast on greenfield apps; RTK when organizational scale or existing Redux investment dominates. Neither replaces server-state libraries — do not store fetched API lists in either unless you enjoy manual cache invalidation.

Server state belongs in a cache layer

Server state is asynchronous, shared, and authoritative — the backend wins on conflicts. Hand-rolling useEffect + useState for every endpoint leads to duplicated loading flags, race conditions on fast navigation, and stale screens after mutations.

TanStack Query (React Query) models server state as cached queries keyed by stable identifiers. It handles background refetch, deduplication, pagination, optimistic updates, and invalidation after mutations. Pair it with REST or GraphQL; the library is transport-agnostic.

  • Queries — read operations with staleTime and gcTime tuning how fresh data must be before refetch.
  • Mutations — writes that invalidate related query keys on success.
  • Prefetching — hover on a link prefetches detail data before navigation.

Keep derived UI state out of the cache: selected row index, expanded accordion IDs, and form drafts stay client-side. The cache holds server truth; components merge it with local state at render time. This separation prevents the classic bug where a refetch wipes unsaved form input.

URL state — the store users can share

Filters, sorts, tabs, and pagination belong in the URL when users expect to copy a link and see the same view. Framework routers (Next.js App Router, React Router, Remix) expose search params as first-class state. Benefits:

  • Back/forward navigation works without custom history stacks.
  • Support teams can reproduce bugs from a pasted URL.
  • SSR frameworks can read params on the server for the first paint — no client-only flash of default filters.

Serialize complex objects carefully: prefer flat keys (?status=open&assignee=me) over giant base64 blobs. Validate and coerce types on read — query strings are always strings. Sync URL updates with debounced search inputs so every keystroke does not pollute browser history.

Re-render traps and how to avoid them

State management bugs often manifest as performance problems, not wrong pixels:

  • Unstable object references — inline style={{ color: 'red' }} or onClick={() => doThing()} in JSX creates new references every render, defeating React.memo on children.
  • Subscribing to entire store slicesuseStore() without a selector re-renders on any field change. Always select the minimum shape you need.
  • Deriving in render without memoization — filtering a 10k-row array on every parent render. Move heavy derivation to memoized selectors or compute in the store.
  • Mixing server and client updates — optimistic cart add that never rolls back when the mutation fails leaves UI and server permanently diverged.

Use React DevTools Profiler and why-did-you-render-style debugging sparingly but seriously before reaching for virtualization or useDeferredValue. Often the fix is a selector, not a new library.

Decision framework — which tool when?

SituationReach for
One component, ephemeral UIuseState / useReducer
Subtree sharing, rare updatesScoped Context
App-wide client state, moderate sizeZustand (or Jotai for atomic model)
Large team, strict action logs, RTK Query alreadyRedux Toolkit
API data, caching, mutationsTanStack Query / SWR / RTK Query
Shareable filters, tabs, paginationURL search params
High-frequency streams (audio, gestures)Refs, external mutable store, not Context

Add complexity only when pain is measurable: profiler screenshots, bug tickets about stale data, or onboarding docs that say "do not touch the global store without a senior review."

Production checklist

  • Classify each piece of state into local, shared client, server, or URL before choosing a library.
  • Colocate by default; document why state was lifted or globalized.
  • Never store authoritative server records only in client global state without a sync strategy.
  • Use selectors and split contexts to limit re-render blast radius.
  • Define query keys and invalidation rules for every mutation endpoint.
  • Put shareable navigation state in the URL; keep secrets and PII out of query strings.
  • Test loading, error, empty, and stale states — not just the happy path.
  • Instrument slow renders in production; state shape changes are a common regression source.

Key takeaways

  • Frontend state is not one blob — local, shared client, server, and URL need different tools.
  • Context is for low-frequency shared values; high-frequency or large stores need selectors or dedicated libraries.
  • TanStack Query (or equivalent) should own server/async state — not useEffect soup.
  • URL params are underrated global state for filters and navigation-shaped data.
  • Most performance issues are subscription granularity problems, not missing Redux.
  • Add global state when colocated patterns demonstrably fail — not preemptively on project day one.

Related reading