State management is where React architecture becomes especially important. A small application can use useState and props. A larger application may need Context, reducers, Redux Toolkit, selectors, async workflows, normalized data, persistence, and clear boundaries between client state and server state. This chapter includes a complete Redux Toolkit example instead of stopping at definitions.
Understand the different kinds of state
Before selecting a state-management tool, identify what kind of state you have. Local UI state, shared client state, authentication state, form state, and server state have different lifecycles.
flowchart TD
A[Application data] --> B[Local UI state]
A --> C[Shared client state]
A --> D[Authentication state]
A --> E[Form state]
A --> F[Server state]
B --> G[useState or useReducer]
C --> H[Context or Redux Toolkit]
D --> I[Context or state store]
E --> J[Form library]
F --> K[API or server-state solution]
Start with local state
If only one component needs a value, do not move it into global state simply because Redux or Context exists.
function QuantityPicker() {
const [quantity, setQuantity] = useState(1);
return (
<button onClick={() => setQuantity(q => q + 1)}>
Quantity: {quantity}
</button>
);
}
Context API complete example
Context is excellent when the main requirement is making a value available to a component subtree.
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const value = useMemo(
() => ({
theme,
toggleTheme: () =>
setTheme(current =>
current === "light" ? "dark" : "light"
)
}),
[theme]
);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
function Header() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<header className={theme}>
<button onClick={toggleTheme}>
Theme: {theme}
</button>
</header>
);
}
Why Context is not automatically Redux
Context provides dependency sharing through a component tree. It does not automatically provide a complete architecture for actions, reducers, selectors, middleware, debugging, or complex state transitions. You can build those things yourself, but Redux Toolkit already provides a structured approach for them.
Redux Toolkit architecture
flowchart LR
A[React component] --> B[dispatch action]
B --> C[Redux store]
C --> D[Slice reducer]
D --> C
C --> E[Selector]
E --> A
F[API request] --> G[Async thunk]
G --> B
Install Redux Toolkit
npm install @reduxjs/toolkit react-reduxRedux Toolkit contains the core Redux tools. react-redux connects the Redux store to React components.
Create a real cart slice
Consider an e-commerce cart. The cart is shared by the product list, product details, header, checkout page, and order summary. A slice can own the cart state and actions.
import { createSlice } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: {
items: [],
coupon: null
},
reducers: {
addItem(state, action) {
const product = action.payload;
const existing = state.items.find(
item => item.productId === product.productId
);
if (existing) {
existing.quantity += product.quantity;
} else {
state.items.push(product);
}
},
removeItem(state, action) {
state.items = state.items.filter(
item => item.productId !== action.payload
);
},
changeQuantity(state, action) {
const { productId, quantity } = action.payload;
const item = state.items.find(
item => item.productId === productId
);
if (item) {
item.quantity = quantity;
}
},
clearCart(state) {
state.items = [];
state.coupon = null;
}
}
});
export const {
addItem,
removeItem,
changeQuantity,
clearCart
} = cartSlice.actions;
export default cartSlice.reducer;The mutation-like syntax is handled safely by Redux Toolkit's Immer integration. The resulting state update remains immutable from Redux's perspective.
Create the Redux store
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./cartSlice";
export const store = configureStore({
reducer: {
cart: cartReducer
}
});
Connect Redux to React
import { Provider } from "react-redux";
import { store } from "./store";
createRoot(document.getElementById("root")).render(
<Provider store={store}>
<App />
</Provider>
);
Dispatching an action
import { useDispatch } from "react-redux";
import { addItem } from "./cartSlice";
function ProductCard({ product }) {
const dispatch = useDispatch();
function addToCart() {
dispatch(
addItem({
productId: product.id,
name: product.name,
price: product.price,
quantity: 1
})
);
}
return (
<button onClick={addToCart}>
Add to Cart
</button>
);
}
Reading state with useSelector
import { useSelector } from "react-redux";
function CartBadge() {
const count = useSelector(
state => state.cart.items.reduce(
(total, item) => total + item.quantity,
0
)
);
return <span>Cart ({count})</span>;
}
Create selectors
Selectors keep state-access logic reusable and make components easier to read.
export const selectCartItems =
state => state.cart.items;
export const selectCartCount = state =>
state.cart.items.reduce(
(total, item) => total + item.quantity,
0
);
export const selectCartTotal = state =>
state.cart.items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
);
Complete cart output
Suppose the user adds a laptop priced at ₹80,000 and a mouse priced at ₹2,000.
Visible output:
Products
Laptop ₹80,000 [Add to Cart]
Mouse ₹2,000 [Add to Cart]
Header:
Cart (2)
Cart Summary:
Laptop ₹80,000 x 1
Mouse ₹2,000 x 1
-----------------------
Subtotal ₹82,000If the user adds another mouse, the same product item is updated instead of creating an unnecessary duplicate row.
Cart (3)
Laptop ₹80,000 x 1
Mouse ₹2,000 x 2
-----------------------
Subtotal ₹84,000
Async Redux with createAsyncThunk
Redux Toolkit also provides createAsyncThunk for common asynchronous workflows. A realistic example is loading orders from a backend API.
import {
createAsyncThunk,
createSlice
} from "@reduxjs/toolkit";
export const fetchOrders = createAsyncThunk(
"orders/fetchOrders",
async () => {
const response = await fetch("/api/orders");
if (!response.ok) {
throw new Error("Unable to load orders");
}
return response.json();
}
);
Handle pending fulfilled rejected
const ordersSlice = createSlice({
name: "orders",
initialState: {
items: [],
status: "idle",
error: null
},
reducers: {},
extraReducers: builder => {
builder
.addCase(fetchOrders.pending, state => {
state.status = "loading";
state.error = null;
})
.addCase(fetchOrders.fulfilled, (state, action) => {
state.status = "succeeded";
state.items = action.payload;
})
.addCase(fetchOrders.rejected, (state, action) => {
state.status = "failed";
state.error = action.error.message;
});
}
});
Orders component output
function Orders() {
const dispatch = useDispatch();
const {
items,
status,
error
} = useSelector(state => state.orders);
useEffect(() => {
if (status === "idle") {
dispatch(fetchOrders());
}
}, [status, dispatch]);
if (status === "loading") {
return <p>Loading orders...</p>;
}
if (status === "failed") {
return <p>Error: {error}</p>;
}
if (items.length === 0) {
return <p>No orders found.</p>;
}
return (
<ul>
{items.map(order => (
<li key={order.id}>
{order.id} - {order.status}
</li>
))}
</ul>
);
}
Complete async output
Initial:
Loading orders...
Success:
Orders
ORD-1001 - Approved
ORD-1002 - Pending
ORD-1003 - Shipped
Failure:
Unable to load orders
Redux middleware
Redux middleware sits between dispatching an action and the reducer processing it. It is useful for async workflows, logging, analytics, or other cross-cutting behavior. Redux Toolkit configures useful defaults for modern Redux applications.
Redux DevTools
Redux DevTools can show dispatched actions and state changes, which is valuable when debugging complex state transitions. Instead of asking where a value changed, developers can inspect the action sequence that produced the state.
Normalize complex collections
Large entity collections can become inefficient if the same object is duplicated in multiple places. Redux Toolkit provides utilities such as createEntityAdapter for normalized entity state when the application needs that pattern.
Context vs Redux Toolkit
| Concern | Context | Redux Toolkit |
|---|---|---|
| Tree-wide value sharing | Excellent | Possible |
| Complex state transitions | Manual architecture | Structured with slices |
| Action history | Not built in | Built around actions |
| Async workflows | Manual | Strong tooling |
| Debugging tools | Basic | Strong Redux tooling |
| Small global value | Often enough | May be unnecessary |
What should NOT automatically go into Redux
- Temporary input text used by one component.
- Simple modal visibility local to one screen.
- Component-specific hover state.
- Every API response just because it came from a server.
- Values that have no shared consumers.
A production state architecture
flowchart TD
A[React app] --> B[Local state]
A --> C[Context]
A --> D[Redux Toolkit]
A --> E[API layer]
B --> F[Local UI behavior]
C --> G[Theme Auth Config]
D --> H[Complex shared client state]
E --> I[Backend]
I --> J[Server data]
Common mistakes
- Putting all application data into Redux without classification.
- Creating huge slices with unrelated business domains.
- Writing selectors directly inside dozens of components.
- Duplicating server state unnecessarily.
- Using Context for rapidly changing large data without considering rendering behavior.
- Using Redux simply because the project is large.
Interview questions
- Context vs Redux Toolkit?
- What is a slice?
- What is dispatch?
- What is a selector?
- What does createAsyncThunk do?
- How are loading, success, and failure represented?
- Why does Redux Toolkit allow mutation-like reducer code?
- When should state remain local?
Key takeaways
- Classify state before choosing a tool.
- Context is excellent for dependency sharing through a tree.
- Redux Toolkit gives complex shared client state a predictable architecture.
- Use slices, selectors, actions, and async workflows to keep Redux maintainable.
- Do not make global state a dumping ground.