State Management
Where your data lives, why it matters, and how to make the right call
Learning Objectives
By the end of this module you will be able to:
- Apply the colocation principle to decide whether state belongs locally, lifted, in Context, or in a global store.
- Distinguish source-of-truth state from derived state and explain why storing both creates a synchronization problem.
- Compare Flux/Redux, Zustand, and atomic stores (Jotai) and identify which fits a given scenario.
- Use URL state (query params, History API) to make views shareable and bookmarkable.
- Model a multi-step UI flow using a state machine to prevent impossible states.
Core Concepts
State colocation: the database normalization of frontend
Backend engineers normalize databases: data has one canonical home and everything else derives or references it. The same discipline applies to frontend state — state should live as close as possible to where it is used, and it should have exactly one source of truth.
State lives as close as possible to where it is used. Only lift state up the component tree when another component genuinely needs access to it. Only promote to global store when multiple distant components need it.
When state is local via useState, only that component and its direct children re-render when it updates. This provides a significant performance advantage over unnecessarily lifted state. Locality is not just an aesthetic preference — it is a concrete optimization.
The recommended workflow mirrors iterative backend design: start local, lift when a second component needs access, move to Context or a store only when you can demonstrate genuine sharing requirements. Putting state into global stores preemptively is a common anti-pattern that introduces unnecessary complexity and disconnects state from the components using it.
A practical heuristic: if state needs to pass through more than three hierarchy levels, or through two or three intermediate components multiple times, that signals a need for Context or a state library.
Local, lifted, Context, global: the four levels
| Level | When to use | Re-render scope |
|---|---|---|
Local (useState) | One component owns and uses it | Component + children |
| Lifted | Two sibling components need the same value | Shared parent + subtree |
| React Context | Scattered tree access, ~3-4 nesting levels | All consumers on value change |
| Global store | Application-wide or cross-feature data | Only subscribed components (varies by library) |
Global state has clear, legitimate uses: authenticated user info, shopping cart contents, notification state, theme preferences, persistent settings. The problem is not global state itself — it is reaching for it before it is warranted.
Context API has practical limitations at enterprise scale. Its performance characteristic is that all consumers re-render when the context value changes, making it better suited for narrowly-scoped, infrequently-changing data than for high-frequency updates.
Client state vs. server state
Modern architecture distinguishes between two fundamentally different categories:
- Client state: UI state — open/closed drawers, selected tabs, form input values, hover states. This is data you own entirely on the client.
- Server state: fetched data — it exists remotely, can be modified by others, becomes stale, and requires loading and error states.
These two categories should be managed separately. Mixing them in a single global store is a common source of complexity. Server state belongs in a dedicated library like TanStack Query or SWR, which handle caching, background refetching, and deduplication automatically.
Ephemeral UI state — toggles, form inputs, hover states — should remain local. Global stores should hold shared data needed by multiple distant components.
Derived state and the synchronization tax
The frontend equivalent of database normalization is distinguishing source-of-truth state from derived state. Storing derived values as independent state creates synchronization problems and increases bug surface area — a "synchronization tax" you pay on every future change.
A core design principle: derive, don't duplicate. If a value can be computed from existing state, compute it. Storing both the source and the derived value means they can diverge.
React's useMemo hook caches computed values between renders, preventing redundant calculations when dependencies haven't changed. Vue computed properties do the same with automatic dependency tracking. Both solve the same problem: expensive derivations should not recompute on every render, but they should never be stored as independent state.
Memoized selectors (as in Reselect for Redux) prevent redundant recomputation by caching results and returning cached values when input dependencies haven't changed, enabling both computational efficiency and reference stability for downstream components.
Memoization requires memory and CPU cycles. Profile before applying it. Only add it where measurements confirm real performance problems. Premature memoization can make code worse by adding complexity and stale-closure risk.
The Flux pattern and why it was invented
The Flux pattern was invented at Facebook to solve a specific problem: MVC-style bidirectional data flow created cascading updates where it was impossible to reason about what caused what to change. The solution was enforced unidirectional data flow:
Views dispatch actions → the dispatcher routes them to stores → stores update state → views re-render. No step reaches backward. This prevents the circular dependencies that made the MVC approach unpredictable.
The Flux dispatcher serves as a centralized hub ensuring all mutations go through a known, auditable path. Redux is the direct implementation of this idea: centralized, immutable state management with explicit actions and pure reducers, making changes maximally predictable.
Key Principles
1. Start local, promote deliberately.
Default to useState. Lift only when a second component needs access. Reach for Context when scattered tree access warrants it. Reach for a global store when cross-feature or application-wide sharing is genuinely required.
2. Derive, don't duplicate. Any value that can be computed from existing state should be computed, not stored. Storing derived state is the same mistake as storing a denormalized duplicate column — it creates two sources of truth that can diverge.
3. Separate client state from server state. Server state (fetched data that can become stale) behaves differently from client state (UI-owned values). Use dedicated libraries (TanStack Query, SWR) for server state rather than mixing it into a global UI store.
4. The URL is a state store. For views that users should be able to share, bookmark, or reload, URL query parameters are the appropriate storage layer. Treating the URL as the source of truth eliminates duplicate state and ensures automatic synchronization between the address bar and the UI.
5. Name impossible states out of existence.
Using separate boolean flags (isLoading, isError, hasData) creates combinations that should be impossible but are not. A state machine or a discriminated union eliminates invalid states structurally.
Worked Example
Deciding where state belongs: a search results page
Consider a search results page with these requirements:
- A search input field
- Filters (category, date range)
- Sorted results list
- A detail panel that opens when a result is clicked
Step 1: Identify each piece of state and its consumers.
| State | Who uses it |
|---|---|
| Search query string | Input field, fetch call |
| Active filters | Filter sidebar, fetch call |
| Sort order | Sort control, fetch call |
| Results data | Results list, result count |
| Selected result ID | Results list (highlight), detail panel |
| Detail panel open/closed | Detail panel, layout |
Step 2: Apply the colocation principle.
searchQuery,activeFilters,sortOrderneed to drive a fetch call — they need to live at the level of the page component, or in the URL (see below).resultsis server state — it should live in TanStack Query keyed to the query + filters + sort values, not in a global store.selectedResultIdis needed by both the results list and the detail panel — they share a parent, so lift to the page component.detailPanelOpenis only used by the detail panel itself — keep it local.
Step 3: Move query parameters to the URL.
searchQuery, activeFilters, and sortOrder are exactly the kind of state users expect to share and bookmark. Move them to URL query parameters using useSearchParams (React Router) or equivalent. This means:
- A user can copy the URL and send a search with specific filters to a colleague.
- The browser back button undoes a filter change.
- A page reload restores the view correctly.
/search?q=typescript&category=docs&sort=recent
Any URL-addressable view must be renderable from scratch by parsing the URL alone. If it cannot, you have not fully committed to URL-as-state-store.
Result: No global store is needed at all. Server state lives in TanStack Query. UI state is local or URL-based. The feature is self-contained and testable in isolation.
Compare & Contrast
The global store landscape
| Library | Mental model | Bundle size | Best fit |
|---|---|---|---|
| Redux Toolkit | Centralized, immutable, action/reducer | ~12KB | Large teams, time-travel debugging, complex async with RTK Query |
| Zustand | Single store, direct mutations | ~1-2KB | Lightweight global state, fast adoption, moderate complexity |
| Jotai | Independent atoms, compose upward | ~3KB | Fine-grained subscriptions, feature-level concerns, code splitting |
| MobX / Valtio | Proxy-based, automatic tracking | ~15KB | Mutation-heavy domains, minimal boilerplate preference |
Redux Toolkit enforces the Flux discipline most strictly. Its DevTools integration provides time-travel debugging — the ability to replay state transitions step by step — which is invaluable in large teams managing complex state. RTK Query handles server state co-located with Redux. The cost is boilerplate and overhead that becomes wasteful for simple features.
Zustand enables global state with minimal boilerplate: state and updates are defined in a single place without action types or reducers. Its single-store architecture requires careful selector design to prevent unnecessary component re-renders when unrelated state changes. It is well-suited to moderate complexity.
Jotai and Recoil represent atomic state management: state is composed from independent atoms rather than a single centralized tree. Components subscribe to specific atoms and only re-render when those atoms change — granular subscription by default. Jotai and Recoil differ technically: Jotai uses atom object referential identity while Recoil uses string keys; Jotai's design enables garbage collection of unused atoms, which matters in dynamic applications.
Many production applications combine patterns: Redux Toolkit for application-level global state (authentication, feature flags), Zustand or Jotai for feature-level concerns, and TanStack Query for all server state. This is not inconsistency — it is using the right tool for each scope.
pushState vs replaceState
Both are History API methods that update the browser URL without a page reload. The distinction matters:
pushState()creates a new browser history entry. Use it for navigations the user expects to be able to reverse with the back button — moving between pages, performing a new search.replaceState()modifies the current history entry without creating a new one. Use it for intermediate adjustments within a view — applying a filter, changing sort order, paginating. UsingpushStatefor every filter change would pollute browser history and make the back button frustrating.
replaceState is also preferred on initial page load to prevent the back button from exiting the application.
State machines vs. boolean flags
// Boolean flags: invalid combinations are possible
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [hasData, setHasData] = useState(false);
// isLoading + isError simultaneously? Technically possible. Logically impossible.
// State machine: invalid states are structurally eliminated
const [state, send] = useMachine(fetchMachine);
// state.value is exactly one of: 'idle' | 'loading' | 'success' | 'error'
Boolean flags mirror the classic multiple-boolean anti-pattern that backend engineers know from protocol implementations: it is a compressed state machine where the encoding can express values that should never exist. A proper state machine (XState in React) defines a single active state at any time. Invalid states are not prevented by validation — they are prevented by the type system and the machine's transition table.
State machines also prevent race conditions in async UI flows. From a loading state, a second submit event simply has no defined transition — the machine ignores it. No guard clause needed. No "isSubmitting" flag to track.
Common Misconceptions
"Global stores are more scalable, so I should use them from the start." The opposite is true. Premature use of global stores is a well-documented anti-pattern. Global stores introduce indirection, make it harder to reason about what renders and why, and disconnect state from the code that uses it. Start local. Promote based on actual need, not anticipated future complexity.
"I should store everything in one place for consistency." Client state (UI values) and server state (fetched data) have different semantics, lifecycles, and invalidation requirements. A single Redux store that mixes them results in complex cache management logic that dedicated libraries like TanStack Query handle better out of the box.
"If I store a derived value in state, I can control when it updates." You cannot. You can only try to keep the two values synchronized, and you will eventually fail — on an edge case, an async timing issue, or a refactor. The only way to guarantee consistency between source and derived value is to compute the derived value from the source. The synchronization tax always costs more than the computation.
"URL state is an implementation detail, not real state management." Treating the URL as a secondary concern leads to applications that cannot be bookmarked, shared, or deep-linked — which breaks fundamental browser expectations. Every distinct view or state in a single-page application should have its own unique URL. The URL is a first-class state store for any state the user should be able to restore or share.
"State machines are over-engineering for UI." State machines are appropriate when a component has many states, complex transitions, or orchestrated interactions. Common UI components — menus, dialogs, multi-step forms, async flows — are exactly where impossible-state bugs emerge from boolean flags. XState includes a visualizer that generates interactive diagrams from the machine definition, serving as living documentation. For genuinely simple components with two or three states, traditional state management remains the right choice.
Key Takeaways
- Colocate first. State should live as close as possible to where it is used. Lift only when a second component needs it. Promote to a global store only when cross-feature sharing is genuinely required.
- Derive, don't duplicate. If a value can be computed from existing state, compute it. Storing derived state creates a synchronization problem you will pay for in every future bug.
- Separate client state from server state. Server state is remote, stale-able, and shared — it belongs in TanStack Query or SWR, not in a UI store. Client state is UI-owned and should stay close to the components using it.
- The URL is a state store. Query parameters are the right home for any state the user expects to share, bookmark, or reload. Bidirectional sync is required: UI updates the URL, URL changes update the UI.
- State machines eliminate impossible states by construction. Multiple boolean flags encoding a state machine is a compressed, invalid-state-permitting anti-pattern. For complex async flows and multi-step UI, model the states explicitly. XState makes this practical in React.
Further Exploration
Global State Management
- Redux Toolkit documentation — Centralized, immutable state management with time-travel debugging
- Zustand GitHub README — Minimal global state with minimal boilerplate
- Jotai documentation — Atomic state composition model
Server State Management
- TanStack Query documentation — Query Keys — Definitive reference on cache invalidation for server state
State Machines
- XState documentation — State machine library with visual debugger for JavaScript/TypeScript
Browser APIs
- History API — MDN Web Docs — Reference for pushState, replaceState, and popstate event handling
Selectors & Memoization
- Reselect documentation — Memoized selectors for Redux and reference equality