useReducer Hook — Complex State Logic in React
DodaTech
4 min read
In this tutorial, you'll learn about usereducer hook. We cover key concepts, practical examples, and best practices.
What You'll Learn
Use the useReducer hook for complex state logic — reducers, actions, dispatch, comparing with useState, and building practical examples like a form manager and shopping cart.
Why It Matters
When state logic becomes complex (multiple sub-values, interdependent updates, or next-state depends on previous state), useReducer keeps it predictable and testable.
Real-World Use
A multi-step form with validation, a shopping cart with add/remove/update/clear, a game scoreboard, or any state with multiple transition types.
useReducer vs useState
// useState: simple, one value at a time
const [count, setCount] = useState(0);
// useReducer: complex, multiple transitions
const [state, dispatch] = useReducer(reducer, initialState);
| Aspect | useState | useReducer |
|---|---|---|
| Complexity | Simple | Complex |
| State type | Single value | Object or array |
| Updates | Direct value or function | Dispatched actions |
| Testability | Harder | Easy (pure reducer) |
| Boilerplate | Minimal | More |
| When to use | Independent values | Related values, complex logic |
Basic Reducer
import { useReducer } from "react";
// Reducer: a pure function (state, action) → newState
function counterReducer(state, action) {
switch (action.type) {
case "INCREMENT":
return { count: state.count + 1 };
case "DECREMENT":
return { count: state.count - 1 };
case "RESET":
return { count: 0 };
case "SET":
return { count: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "INCREMENT" })}>+</button>
<button onClick={() => dispatch({ type: "DECREMENT" })}>-</button>
<button onClick={() => dispatch({ type: "RESET" })}>Reset</button>
<button onClick={() => dispatch({ type: "SET", payload: 100 })}>
Set to 100
</button>
</div>
);
}
Reducer with Multiple State Fields
const initialState = {
isLoading: false,
data: null,
error: null,
lastUpdated: null,
};
function fetchReducer(state, action) {
switch (action.type) {
case "FETCH_START":
return { ...state, isLoading: true, error: null };
case "FETCH_SUCCESS":
return {
...state,
isLoading: false,
data: action.payload,
lastUpdated: Date.now(),
};
case "FETCH_ERROR":
return {
...state,
isLoading: false,
error: action.payload,
};
case "CLEAR":
return initialState;
default:
return state;
}
}
function UserData({ userId }) {
const [state, dispatch] = useReducer(fetchReducer, initialState);
const { isLoading, data, error } = state;
async function loadUser() {
dispatch({ type: "FETCH_START" });
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
dispatch({ type: "FETCH_SUCCESS", payload: data });
} catch (err) {
dispatch({ type: "FETCH_ERROR", payload: err.message });
}
}
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>{/* render data */}</div>;
}
Shopping Cart with useReducer
const cartReducer = (state, action) => {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find(i => i.id === action.item.id);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.id === action.item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return {
...state,
items: [...state.items, { ...action.item, quantity: 1 }],
};
}
case "REMOVE_ITEM":
return {
...state,
items: state.items.filter(i => i.id !== action.id),
};
case "UPDATE_QUANTITY":
return {
...state,
items: state.items.map(i =>
i.id === action.id
? { ...i, quantity: Math.max(0, action.quantity) }
: i
),
};
case "CLEAR":
return { ...state, items: [] };
case "APPLY_DISCOUNT":
return { ...state, discount: action.percentage };
default:
return state;
}
};
function ShoppingCart() {
const [cart, dispatch] = useReducer(cartReducer, {
items: [],
discount: 0,
});
const total = cart.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const discountedTotal = total * (1 - cart.discount / 100);
return (
<div>
<h2>Shopping Cart ({cart.items.length} items)</h2>
{cart.items.map(item => (
<div key={item.id}>
<span>{item.name}</span>
<input
type="number"
value={item.quantity}
onChange={e =>
dispatch({
type: "UPDATE_QUANTITY",
id: item.id,
quantity: parseInt(e.target.value),
})
}
/>
<span>${(item.price * item.quantity).toFixed(2)}</span>
<button
onClick={() =>
dispatch({ type: "REMOVE_ITEM", id: item.id })
}
>
×
</button>
</div>
))}
<p>Total: ${discountedTotal.toFixed(2)}</p>
<button onClick={() => dispatch({ type: "CLEAR" })}>
Clear Cart
</button>
<button
onClick={() =>
dispatch({ type: "APPLY_DISCOUNT", percentage: 10 })
}
>
Apply 10% Discount
</button>
</div>
);
}
Testing Reducers
Reducers are pure functions — easy to test:
// cartReducer.test.js
test("adds item to cart", () => {
const initialState = { items: [], discount: 0 };
const action = {
type: "ADD_ITEM",
item: { id: 1, name: "Test", price: 10 },
};
const result = cartReducer(initialState, action);
expect(result.items).toHaveLength(1);
expect(result.items[0].quantity).toBe(1);
});
test("increments quantity for existing item", () => {
const initialState = {
items: [{ id: 1, name: "Test", price: 10, quantity: 1 }],
discount: 0,
};
const action = {
type: "ADD_ITEM",
item: { id: 1, name: "Test", price: 10 },
};
const result = cartReducer(initialState, action);
expect(result.items[0].quantity).toBe(2);
});
When to useReducer
✅ Multiple state fields that change together
✅ Complex state transitions
✅ Next state depends on previous state
✅ State logic is hard to understand with useState
✅ You want to test state logic in isolation
✅ You're building a form with many fields
← Previous
React Context API — Global State Management
Next →
React useRef Hook — Complete Guide with Examples
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro