State Management in React
State management is the way an application stores, updates, shares, and synchronizes data that changes over time.
Whenever a user types into an input, opens a modal, adds an item to a cart, or signs in, some state changes and React renders the UI again.
User action
↓
State update
↓
React renders affected components
↓
The UI reflects the new state
Core idea: Keep each piece of state as close as possible to the components that use it, and move it upward only when it must be shared.
1. State, Props, and Derived Values
These values may look similar, but they have different roles.
| Value | Meaning | Example |
|---|---|---|
| State | Data owned by a component or store that changes over time | isOpen, cartItems |
| Props | Data passed from a parent to a child | product, onAddToCart |
| Derived value | A value calculated from existing state or props | cart total, filtered products |
function Cart({ items }) {
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return <p>Total: ₹{total}</p>;
}
total does not need its own state because it can be calculated from items. Storing both can create two sources of truth that may become inconsistent.
2. First Classify the State
Before choosing a library, identify what kind of state you have.
| State type | Examples | Common home |
|---|---|---|
| Local UI state | input value, tooltip, modal | useState |
| Shared client state | theme, authenticated user, cart | lifted state, Context, or an external store |
| URL state | search query, filters, selected tab | route path or query parameters |
| Server state | products, orders, profile from an API | query/cache library |
| Persistent client state | saved theme, draft preferences | state plus localStorage or another storage layer |
This distinction matters because not every changing value belongs in one global store.
3. Local State with useState
Use local state when only one component, or a small part of its subtree, needs the value.
import { useState } from "react";
function ProductCard({ product }) {
const [quantity, setQuantity] = useState(1);
return (
<article>
<h2>{product.name}</h2>
<p>Quantity: {quantity}</p>
<button onClick={() => setQuantity(q => q + 1)}>
Add one
</button>
</article>
);
}
The updater form, setQuantity(q => q + 1), is useful when the next value depends on the previous value.
Local state is usually the best starting point because it keeps ownership and behavior easy to understand.
4. Lifting State Up
When sibling components need the same state, move it to their closest common parent.
ProductPage
owns cartItems
/ \
↓ ↓
ProductList CartSummary
reads + adds reads
function ProductPage() {
const [cartItems, setCartItems] = useState([]);
function addToCart(product) {
setCartItems(items => [...items, product]);
}
return (
<>
<ProductList onAddToCart={addToCart} />
<CartSummary items={cartItems} />
</>
);
}
This preserves a single source of truth: ProductPage owns the cart, while its children receive data and callbacks through props.
Unidirectional Data Flow
React follows a predictable direction:
Data flows down through props
Events flow up through callbacks
Children should not directly modify parent state. They report an event, and the owner decides how to update the state.
5. Prop Drilling
Prop drilling happens when props pass through several intermediate components only to reach a deeply nested child.
App
└── Layout (forwards theme)
└── Sidebar (forwards theme)
└── ThemeButton (uses theme)
Passing props is not automatically a problem. It is explicit, easy to trace, and often the clearest solution.
It becomes inconvenient when:
- many intermediate components only forward the value;
- unrelated parts of the tree need the same state;
- changing the state shape requires edits across many layers.
Possible solutions include component composition, Context, or an external store. Choose one only when it makes the data flow simpler.
6. Complex Local State with useReducer
useReducer is useful when several related values change through well-defined actions.
import { useReducer } from "react";
function cartReducer(state, action) {
switch (action.type) {
case "itemAdded":
return [...state, action.product];
case "itemRemoved":
return state.filter(item => item.id !== action.productId);
case "cartCleared":
return [];
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Cart() {
const [items, dispatch] = useReducer(cartReducer, []);
return (
<button
onClick={() =>
dispatch({ type: "itemAdded", product: { id: 1, name: "Book" } })
}
>
Add book
</button>
);
}
A reducer centralizes transition logic:
Current state + action → reducer → next state
Use useReducer when it improves clarity—not simply because the component has more than one state value.
7. Sharing State with Context
Context lets a parent make a value available to any descendant without manually passing it through every level.
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
function ThemeButton() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Current theme: {theme}
</button>
);
}
Context works well for values that many descendants need, such as:
- theme;
- locale;
- authenticated user information;
- a shared feature-level state.
Context is primarily a delivery mechanism. The state may still be managed by useState, useReducer, or an external store.
Context with a Reducer
For a shared domain such as a cart, a common pattern is:
Context → makes state and dispatch available
Reducer → defines allowed state transitions
Provider → owns both and exposes them to the subtree
This can be enough for many applications. As the provider grows, split unrelated contexts so that components subscribe only to what they need.
8. External State Stores
An external store keeps shared client state outside the component tree and lets components subscribe to selected parts of it.
Consider one when:
- state is used across distant features;
- updates and business rules have become difficult to coordinate;
- fine-grained subscriptions or selectors are important;
- the team needs debugging tools, middleware, or a consistent architecture.
Popular approaches include:
| Tool | Useful when |
|---|---|
| Redux Toolkit | You want explicit actions, predictable updates, strong tooling, middleware, and conventions |
| Zustand | You want a small store API with selector-based subscriptions and less ceremony |
| MobX | The application benefits from observable, reactive object models |
Redux Toolkit is the officially recommended way to write Redux logic. It reduces the boilerplate associated with older handwritten Redux code.
Do not select a store only because an application is called “large.” Choose it based on state relationships, update patterns, tooling needs, and team conventions.
9. Server State Is Different
Data fetched from an API is not fully owned by the browser. It can become stale, may be shared by multiple screens, and often needs caching, retrying, refetching, and request deduplication.
Client state Server state
------------ ------------
modal open product list from API
selected local tab user profile from API
unsaved form input order history from API
Libraries such as TanStack Query can manage the lifecycle and cache of server data. A general-purpose global store can hold server data, but then the application must implement those behaviors itself.
Keep temporary edits in client state and synchronize them with the server through explicit mutations.
10. URL State and Persistent State
URL State
If a value should survive refreshes, support browser navigation, or be shareable as a link, the URL may be its best source of truth.
/products?category=books&sort=price&page=2
Search filters, pagination, tabs, and selected records are common examples.
Persistent Client State
Storage such as localStorage can preserve a value between sessions, but storage is not a complete state-management system.
When persisting state, consider:
- stale or incompatible data after an application update;
- serialization and migration;
- synchronization between tabs;
- privacy and security—sensitive data should not be stored carelessly.
11. How to Choose an Approach
Use the smallest tool that keeps ownership and updates clear.
Does only one component need it?
└── Yes → useState
Do nearby siblings need it?
└── Yes → lift state to their common parent
Are related transitions becoming complex?
└── Yes → useReducer
Do many descendants need it?
└── Yes → Context, possibly with a reducer
Is shared client state cross-cutting or operationally complex?
└── Yes → consider an external store
Is the server the real owner of the data?
└── Yes → use a server-state/query solution
Should it be shareable through navigation?
└── Yes → put it in the URL
These approaches can coexist. A real application commonly uses local state, URL state, a server cache, and a small amount of shared client state at the same time.
12. Good State Modeling
Well-shaped state is easier to update than poorly shaped state, regardless of the library.
Avoid Contradictory State
Instead of separate flags that can disagree:
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [hasError, setHasError] = useState(false);
use one status with valid states:
const [status, setStatus] = useState("idle");
// "idle" | "loading" | "success" | "error"
Avoid Duplicate State
// Prefer storing the stable identifier.
const [selectedProductId, setSelectedProductId] = useState(null);
const selectedProduct = products.find(
product => product.id === selectedProductId
);
If both the selected object and product list are stored separately, one copy may become outdated.
Update State Immutably
setUser(user => ({
...user,
address: {
...user.address,
city: "Pune",
},
}));
Do not mutate React state directly. Returning new objects and arrays lets React and state tools detect changes predictably. Some libraries, including Redux Toolkit, use Immer so reducer code can look mutative while still producing immutable updates.
13. Example: E-commerce State Design
One application can assign different state to different owners:
| Feature | State | Suitable owner |
|---|---|---|
| Product card | quantity picker | local useState |
| Search page | query, sort, page | URL |
| Product data | products and availability | server-state cache |
| Cart | items and quantities | shared Context/reducer or external store |
| Checkout form | unsaved field values and errors | form/local state |
| Theme | light or dark mode | Context plus persistence |
The goal is not one store for the whole application. The goal is a clear owner for every value.
14. Common Mistakes
| Mistake | Better approach |
|---|---|
| Putting every value in global state | Keep state local until sharing is necessary |
| Copying props into state | Use the prop directly unless an intentional snapshot is required |
| Storing values that can be calculated | Derive them during rendering |
| Synchronizing the same value across several stores | Keep one source of truth |
| Using Context as one huge application store | Split by concern and watch provider update frequency |
| Treating API data like permanent client data | Use a cache designed for server-state lifecycles |
| Choosing a library only by application size | Evaluate ownership, transitions, subscriptions, and tooling |
| Mutating objects or arrays directly | Create a new value or use an immutable update helper |
Official References
- React: Managing State
- React: Choosing the State Structure
- React: Passing Data Deeply with Context
- Redux Toolkit Overview
- Zustand Documentation
- TanStack Query Overview
Summary
Good state management is less about choosing the biggest library and more about giving every changing value one clear owner.
Think of state as a map of ownership:
Where does this value come from?
Who is allowed to update it?
Who needs to read it?
How long should it live?
Answer those questions first. The appropriate hook, Context, store, URL, or server cache usually becomes clear afterward.