Skip to main content

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.

ValueMeaningExample
StateData owned by a component or store that changes over timeisOpen, cartItems
PropsData passed from a parent to a childproduct, onAddToCart
Derived valueA value calculated from existing state or propscart 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 typeExamplesCommon home
Local UI stateinput value, tooltip, modaluseState
Shared client statetheme, authenticated user, cartlifted state, Context, or an external store
URL statesearch query, filters, selected tabroute path or query parameters
Server stateproducts, orders, profile from an APIquery/cache library
Persistent client statesaved theme, draft preferencesstate 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:

ToolUseful when
Redux ToolkitYou want explicit actions, predictable updates, strong tooling, middleware, and conventions
ZustandYou want a small store API with selector-based subscriptions and less ceremony
MobXThe 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:

FeatureStateSuitable owner
Product cardquantity pickerlocal useState
Search pagequery, sort, pageURL
Product dataproducts and availabilityserver-state cache
Cartitems and quantitiesshared Context/reducer or external store
Checkout formunsaved field values and errorsform/local state
Themelight or dark modeContext plus persistence

The goal is not one store for the whole application. The goal is a clear owner for every value.


14. Common Mistakes​

MistakeBetter approach
Putting every value in global stateKeep state local until sharing is necessary
Copying props into stateUse the prop directly unless an intentional snapshot is required
Storing values that can be calculatedDerive them during rendering
Synchronizing the same value across several storesKeep one source of truth
Using Context as one huge application storeSplit by concern and watch provider update frequency
Treating API data like permanent client dataUse a cache designed for server-state lifecycles
Choosing a library only by application sizeEvaluate ownership, transitions, subscriptions, and tooling
Mutating objects or arrays directlyCreate a new value or use an immutable update helper

Official References​


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.