The State of State Management
Every year, a new state management library promises to solve React's "state problem." But after building production apps with Redux, MobX, Zustand, Jotai, Recoil, and React's built-in tools, I've concluded that the real problem isn't the library — it's over-engineering state architecture for the complexity you actually have.
Most React applications need far less state management than developers think. The secret is categorizing your state correctly and using the simplest tool for each category.
The State Categories
Server State
Data fetched from an API: user profiles, product lists, order history. This is the most common type of state in web applications, and it has specific requirements: caching, background refetching, optimistic updates, and invalidation.
Use a dedicated server state library: TanStack Query (formerly React Query) or SWR. These libraries handle caching, deduplication, background refetching, and stale-while-revalidate patterns out of the box. Before TanStack Query, I spent weeks implementing this logic manually in Redux. Now it takes five minutes.
If you're using Next.js with Server Components, server state management is even simpler — data fetching happens on the server, and the results are passed as props. You might not need a client-side server state library at all.
UI State
State that exists only in the UI: whether a modal is open, which tab is selected, the current value of a form input, whether a sidebar is collapsed. This state doesn't persist, isn't shared with the server, and usually scoped to a single component or small component tree.
For UI state, useState and useReducer are almost always sufficient. If you need to share UI state across distant components, useContext works for low-frequency updates (theme, locale, user preferences). Don't reach for a global state library just because two components need to share a boolean.
Client State
State that lives on the client but isn't directly tied to server data: shopping cart contents (before checkout), form wizard progress, draft messages, user preferences that haven't been saved. This is the gray area where external state management libraries sometimes make sense.
Zustand is my recommendation for client state. It's tiny (1KB), has a simple API, works outside of React (useful for testing), and scales from one store to many without ceremony. The mental model is straightforward: create a store with state and actions, subscribe to it from components.
URL State
State that should be reflected in the URL: search filters, pagination, selected tabs, sort order. This state needs to survive page refreshes and be shareable via links. Use the URL itself as the source of truth, read it with your router's hooks, and update it with navigation.
This is state management that developers often overlook, implementing filters in useState and then being surprised when users can't bookmark or share their filtered view.
When You Actually Need Redux
Redux still has legitimate use cases, but they're narrower than its popularity suggests. Use Redux when you have complex state transitions that benefit from a strict action/reducer pattern, you need time-travel debugging for complex UIs, multiple teams are contributing to the same state layer, or you have significant middleware needs (sagas, thunks with complex flows).
If you're reaching for Redux because "it's the standard," pause and evaluate whether TanStack Query for server state plus Zustand for client state would be simpler. In my experience, this combination replaces Redux in 80% of applications with less code and less complexity.
Patterns That Scale
Colocation
Keep state as close to where it's used as possible. If only one component needs a piece of state, put it in that component. If a parent and its children need it, lift it to the parent. Only hoist state to a global store when it genuinely needs to be global.
This principle sounds simple but requires discipline. The temptation to "just put it in the global store" is strong, especially in teams used to Redux. Resist it — global state is shared mutable state, and every piece of global state is a coupling point between components.
Derived State
Don't store state that can be computed. If you have a list of items and need a filtered count, compute the count from the list — don't maintain a separate counter. Libraries like Zustand support selectors that derive values from the store, and React's useMemo hook handles derived state within components.
Storing derived state is a bug factory: every time you update the source, you need to remember to update the derived value. Forget once, and you have an inconsistency.
State Machines for Complex Flows
Multi-step forms, checkout flows, and wizard UIs benefit from explicit state machines. Libraries like XState model these as finite state machines with defined states, transitions, and guards. This eliminates impossible states and makes complex flows predictable and testable.
A checkout flow modeled as a state machine can only go from "cart" to "shipping" to "payment" to "confirmation" — never from "cart" directly to "confirmation" or back from "confirmation" to "payment" without an explicit transition.
Performance Considerations
Selective Subscriptions
Global stores cause re-renders whenever any part of the store changes, unless you use selective subscriptions. Zustand's useStore hook with a selector only re-renders when the selected slice changes. TanStack Query re-renders only when the specific query data you've subscribed to changes.
Always subscribe to the smallest slice of state your component needs. A component that displays the user's name should subscribe to store.user.name, not the entire store.
Avoiding Context Pitfalls
React Context re-renders every consumer when the provider value changes. If your context value is an object that's recreated on every render, every consumer re-renders on every render — even if the specific value they care about didn't change.
Split large contexts into smaller, focused contexts (one for theme, one for user, one for feature flags). Or use a state management library instead — they handle granular subscriptions natively.
My Standard Stack
For new React projects, my default state management stack is: TanStack Query for server state, useState and useReducer for component-local UI state, Zustand for shared client state (if needed), and URL params for filterable or shareable state. This covers every real-world need I've encountered, with minimal boilerplate and excellent developer experience.