State Management: Local, Global, and Server State
Concept
"State management" reads like a single problem with a single library answer, but it's actually several distinct categories of state that behave differently and want different tools — treating them as one problem is where most state-management pain comes from. The categories that matter in practice:
- Local UI state — a toggle, an input value before submit, whether a dropdown is open. Lives and dies with the component; nothing outside needs to see it.
- Global client state — state genuinely shared across distant, unrelated parts of the component tree: theme, a cart drawer's open/closed state, feature-flag overrides set client-side.
- Server state — data that actually lives on a backend, can change independently of anything the current user does, and was fetched asynchronously: a user profile, an order history, a product list. This is fundamentally different from client state because it can go stale the moment it's fetched, needs retry/dedupe/cache-invalidation logic, and multiple components may want the same data without re-fetching it separately.
- URL state — anything that should be shareable or bookmarkable (a search filter, a pagination page, a selected tab) belongs in the URL, not in memory.
- Form state — input values, validation errors, and submission status while a form is being filled out; typically short-lived and localized to the form itself.
The category-first framing matters because each one has a purpose-built answer: useState/useReducer for local UI state, a lightweight global store (Zustand, Jotai) for genuinely cross-cutting client state, a server-state library (TanStack Query, SWR) for anything fetched from an API, useSearchParams for URL state, and a form library (React Hook Form, paired with a schema validator) for form state. Reaching for one tool to cover all five categories is exactly the pattern that produces bloated global stores and stale-cache bugs.
Tradeoffs
| Tool | Strengths | Weaknesses | Good fit for |
|---|---|---|---|
useState/useReducer (local) |
Simplest, colocated with the component, zero dependencies | Doesn't share across distant components; lifting it up too far reintroduces prop drilling | Ephemeral UI state — toggles, unsaved input, open/closed flags |
| Context API | Built into React, no extra dependency | Re-renders every consumer on any value change; not built for high-frequency updates | Small, infrequently-changing shared state — theme, locale, auth-user object |
| Zustand / Jotai (global store) | Selective subscriptions (a component only re-renders for the slice it reads), small bundle versus Redux + React-Redux | Still hand-managed state — another mental model and dependency to reason about | Cross-cutting client state genuinely needed app-wide, not data that came from a server |
| TanStack Query / SWR (server state) | Built-in caching, request dedupe, retries, background refetch, stale-time control | Overkill for purely local or derived state; introduces its own concepts (query keys, invalidation) that have to be learned | Any data fetched from a backend API |
The practical pattern most teams converge on: start local by default, promote to a global client-state store only when a genuine cross-tree sharing need appears, and never let server data enter local useState or a global store as its long-term home — a server-state library should own the cache for anything that came from fetch.
When to use / when not to
- Default to
useStatefor anything a single component (or its direct children via props) can own; only lift state up or move it to a store when a real sharing need appears, not preemptively. - Route all data fetched from a backend through a server-state library — never manage it with
useState+useEffect, even for "just one simple GET request." The complexity that library hides (race conditions on unmount, deduping identical in-flight requests, cache invalidation) reappears by hand the moment a second component needs the same data. - Reach for a global client-state store only for state that's genuinely used across unrelated, non-nested parts of the tree — most state (a commonly cited rule of thumb puts it around 80%) should stay local.
- Put anything shareable or bookmarkable (filters, search queries, selected tabs, pagination) in the URL, not in component state — otherwise a shared link or a page refresh silently loses it.
- Don't duplicate server data into a global store "for convenience" — it now has two sources of truth that can drift, and the store's copy won't get the server-state library's automatic revalidation.
Common pitfall
Fetching data with useEffect and storing the response in local useState — this quietly rebuilds a small, incomplete state machine for every single API call: loading/error/success flags managed by hand, no protection against a component unmounting mid-fetch, no deduplication when two components independently request the same data, and no automatic refetch when the underlying data goes stale. None of this is wrong in isolation, but it's exactly what a server-state library already solves, tested and for free — reimplementing it ad hoc per component is where subtle race-condition bugs (setting state on an unmounted component, a slower earlier request overwriting a faster later one) tend to live.
Engineering Lens
The transferable skill here isn't "know which library to pick" — it's correctly categorizing a given piece of state (local, global-client, server, URL, form) before reaching for any tool at all, since the categorization determines the answer almost automatically. This is the same discipline that shows up in rendering-strategy decisions (Fundamentals/rendering-strategies-ssr-csr-ssg-isr in this same topic folder): asking "what kind of thing is this, actually?" before optimizing tends to dissolve problems that look hard when framed as "which library should I use."
Sources
- State Management Trends in React 2025: When to Use Zustand, Jotai, XState, or Something Else — Makers Den
- Stop Using useEffect for Data Fetching: Understanding TanStack Query — DEV Community
- React patterns to avoid common pitfalls in local state management — LogRocket Blog