useReducer
What is useReducer?
useReducer is a React hook for managing state that involves multiple sub-values or where the next state depends on the previous state in complex ways. It's an alternative to useState that scales better for non-trivial state logic.
It follows the reducer pattern: you dispatch an action, a reducer function calculates the next state, and the component re-renders.
const [state, dispatch] = useReducer(reducer, initialState);
state— current state valuedispatch— function to send actionsreducer— a pure function(state, action) => newStateinitialState— the starting state
useState vs useReducer
| Scenario | Use |
|---|---|
| Single value, simple updates | useState |
| Multiple related values | useReducer |
| Next state depends on previous | useReducer |
| Complex update logic | useReducer |
| Shared logic across multiple state updates | useReducer |
Your First useReducer
A counter — the simplest example:
import { useReducer } from "react";
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "reset":
return { count: 0 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</>
);
}
The reducer is a pure function — it takes the current state and an action, and returns the new state. It never mutates state directly.
The default: throw new Error(...) in the switch is intentional. It catches typos in action types immediately rather than silently returning stale state.
Actions with Payloads
Actions can carry data in a payload field:
function reducer(state, action) {
switch (action.type) {
case "set_name":
return { ...state, name: action.payload };
case "set_age":
return { ...state, age: action.payload };
case "reset":
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// Dispatching with payload
dispatch({ type: "set_name", payload: "Rizwan" });
dispatch({ type: "set_age", payload: 25 });
Real-World Example: Form State
Managing a multi-field form with useState means one useState call per field. With useReducer, all form state lives together:
const initialState = {
name: "",
email: "",
password: "",
errors: {},
isSubmitting: false,
};
function formReducer(state, action) {
switch (action.type) {
case "set_field":
return {
...state,
[action.field]: action.value,
errors: { ...state.errors, [action.field]: null },
};
case "set_errors":
return { ...state, errors: action.payload, isSubmitting: false };
case "submit_start":
return { ...state, isSubmitting: true, errors: {} };
case "submit_success":
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function SignupForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleChange = (e) => {
dispatch({ type: "set_field", field: e.target.name, value: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
dispatch({ type: "submit_start" });
try {
await api.register(state);
dispatch({ type: "submit_success" });
} catch (err) {
dispatch({ type: "set_errors", payload: err.errors });
}
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={state.name}
onChange={handleChange}
placeholder="Name"
/>
{state.errors.name && <p className="error">{state.errors.name}</p>}
<input
name="email"
value={state.email}
onChange={handleChange}
placeholder="Email"
/>
{state.errors.email && <p className="error">{state.errors.email}</p>}
<button disabled={state.isSubmitting}>
{state.isSubmitting ? "Submitting..." : "Sign Up"}
</button>
</form>
);
}
All the state transitions are named and centralized in the reducer — easy to read, test, and debug.
Combining useReducer with Context
useReducer + Context is the React-native alternative to Redux for global state. You saw the Context API pattern in the previous section — here's how to combine them:
import { createContext, useContext, useReducer } from "react";
const CartContext = createContext(null);
export function cartReducer(state, action) {
switch (action.type) {
case "add_item": {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map((i) =>
i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] };
}
case "remove_item":
return {
...state,
items: state.items.filter((i) => i.id !== action.payload),
};
case "clear":
return { items: [] };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used inside <CartProvider>");
return context;
}
Usage in any component:
function ProductCard({ product }) {
const { dispatch } = useCart();
return (
<button onClick={() => dispatch({ type: "add_item", payload: product })}>
Add to Cart
</button>
);
}
function CartSummary() {
const { state, dispatch } = useCart();
const total = state.items.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<div>
<p>{state.items.length} items — ${total.toFixed(2)}</p>
<button onClick={() => dispatch({ type: "clear" })}>Clear Cart</button>
</div>
);
}
Testing Reducers
Since reducers are pure functions, they're trivial to test — no mocks, no setup:
import { cartReducer } from "./CartContext";
test("adds new item to cart", () => {
const state = { items: [] };
const action = { type: "add_item", payload: { id: 1, name: "Book", price: 20 } };
const result = cartReducer(state, action);
expect(result.items).toHaveLength(1);
expect(result.items[0].qty).toBe(1);
});
test("increments quantity for existing item", () => {
const state = { items: [{ id: 1, name: "Book", price: 20, qty: 1 }] };
const action = { type: "add_item", payload: { id: 1, name: "Book", price: 20 } };
const result = cartReducer(state, action);
expect(result.items[0].qty).toBe(2);
});
This testability is one of the biggest advantages of the reducer pattern.