STATE MANAGEMENT APPROACHES IN REACT
One of the most common sources of complexity in React applications is state management — not because it is inherently hard, but because teams often reach for heavy solutions too early. useState gets dismissed as "too simple," so someone installs Redux on day one, and now every trivial piece of UI state must pass through a reducer, an action creator, and a selector.
The right approach scales with your needs. This article walks through every major option, when each one fits, and how to recognize when you have outgrown it.
USESTATE: THE DEFAULT STARTING POINT
useState is not a fallback for apps that aren't complex enough for real state management. It is the right tool for component-local state — anything that only one component and its direct children care about.
A login form is the textbook example. You have three pieces of state that live entirely within the form component: the current field values (email and password), a loading boolean, and a nullable error string. useState handles all three cleanly. The handleSubmit function sets loading to true, clears any previous error, awaits the login call, and either proceeds on success or sets the error string on failure. The state never needs to leave the component tree.
Use useState when state belongs to a single component or a tight parent-child pair and is unlikely to be needed anywhere else.
Warning signs that it is time to move on: you are drilling state through three or more levels of props, or multiple unrelated components need access to the same piece of state.
USEREDUCER: COMPLEX LOCAL STATE
When component state has multiple sub-values with interdependent update logic, useReducer gives you structure without leaving the component. Think of it as a tiny Redux living inside a single component or custom hook.
The classic pattern is a generic data-fetching hook. The state shape has three fields: a status string (idle, loading, success, or error), nullable data of some generic type, and a nullable error message. Each of those fields should only change together in well-defined ways — you never want status to be "success" while data is null, or "error" with no error message. A reducer enforces those invariants by accepting explicit action types like FETCH_START, FETCH_SUCCESS, FETCH_ERROR, and RESET, and returning a consistent new state for each one. The reducer function is pure and has no React dependencies, which means you can unit-test it directly without mounting any components.
The hook wires the reducer to useEffect: when the URL changes, it dispatches FETCH_START, fires the fetch, then dispatches either FETCH_SUCCESS with the parsed data or FETCH_ERROR with the error message. Consumers get back the full state object and can branch on the status field to render a spinner, an error message, or the actual content.
Use useReducer when you have three or more related state variables that change together, when state transitions have names worth documenting for future maintainers, or when you want logic that is unit-testable in isolation.
CONTEXT API: SHARED STATE WITHOUT PROP DRILLING
Context is the standard solution for app-wide values: current user, theme, locale, feature flags. It eliminates prop drilling without pulling in an external library.
The pattern is to create a typed context with a null default, then build a provider component that owns the actual state with useState. The provider computes its context value with useMemo so the reference stays stable between renders, wraps its children in the context provider, and exports a custom hook like useAuth that calls useContext internally and throws a helpful error if it is called outside the provider. That early throw is important — component authors see the mistake immediately rather than getting a cryptic undefined access.
The auth provider example carries user state, a loading flag (populated from a getSession call on mount), a login function that calls the API and updates user state, and a logout function that calls the logout API and clears user state. Any component in the tree can call useAuth and get the user object or the logout function without any props being threaded through.
Context's main footgun is performance. Every consumer re-renders when the context value reference changes. Always memoize the value object in the provider, and split high-frequency values from low-frequency values into separate contexts.
Use Context when data is truly global, changes infrequently, and you want zero extra dependencies.
ZUSTAND: LIGHTWEIGHT GLOBAL STATE
Zustand is the library you reach for when Context becomes painful — when you have global state that updates frequently, when many unrelated components need to subscribe, or when you want selector-based subscriptions to avoid wasteful re-renders.
The setup is minimal. You call create with a function that receives set and get and returns an object describing your state and its mutators. The Immer middleware lets you write mutations that look like direct property assignments while producing immutable updates under the hood. The devtools middleware hooks up to the Redux DevTools browser extension for free.
A shopping cart store illustrates the pattern well. The state holds an array of cart items. Mutators include addItem (which increments quantity if the item already exists, or pushes a new entry), removeItem, updateQuantity, and clearCart. A computed total function uses get to read the current items and reduce them.
The crucial advantage over Context is selector-based subscriptions. A CartIcon component that only needs the item count calls useCartStore with a selector that returns items.length. It will only re-render when that count changes — not when prices update, not when names change, not when any other field in the store mutates. This granularity is impossible to achieve cleanly with Context alone.
Use Zustand when you need global state that updates often, components need to subscribe to specific slices, or you want minimal boilerplate with first-class TypeScript support.
REDUX TOOLKIT: WHEN YOU ACTUALLY NEED IT
Redux has a reputation for verbosity, but Redux Toolkit eliminates most of it. The real question is not Redux vs. Zustand — it is whether you need what Redux uniquely provides.
Redux's genuine strengths: RTK Query for server state management with built-in caching, invalidation, and optimistic updates; time-travel debugging through the Redux DevTools which are the best in class; large teams where strict action and reducer boundaries enforce consistency across many contributors; and complex inter-slice logic where middleware and extraReducers handle cascading state updates cleanly.
The createSlice function takes a name, an initial state, and a reducers map, and generates action creators and action types automatically. The createAsyncThunk helper handles async operations and integrates with the slice's extraReducers via pending, fulfilled, and rejected lifecycle cases. With the Immer integration baked in, reducer logic reads like direct mutation even though Immer produces immutable updates.
Use Redux Toolkit when you have a large team that benefits from strict conventions, when you need time-travel debugging, when you want RTK Query for server state management, or when your state has complex cross-slice relationships that would become unwieldy in Zustand.
THE DECISION FRAMEWORK
Work through these questions in order:
Does only one component or a tight parent-child pair need this state? Use useState or useReducer.
Is this a slow-changing global value like auth, theme, locale, or feature flags? Use the Context API with a memoized value.
Is this global state that updates frequently, or do components need fine-grained subscriptions to avoid wasteful re-renders? Use Zustand.
Do you have a large team that needs strict conventions, time-travel debugging, RTK Query, or complex inter-slice logic? Use Redux Toolkit.
The most common mistake is skipping to the last option on day one. Most production apps — even sizeable ones — are well-served by a combination of the first three. Reach for external libraries when you feel the pain, not in anticipation of it.
Back to Methods
MethodIntermediate
State Management Approaches in React
Compare different state management solutions: useState, useReducer, Context, Zustand, and Redux.
February 5, 202420 min read
ReactState ManagementArchitecture