Learn how to manage complex state logic with useReducer, when to use it over useState, and how to build scalable state management patterns.
Imagine you're managing a complex machine with many buttons, switches, and dials. useState is like having separate controls for each part, but sometimes you need a central control panel that coordinates everything based on what action you want to perform.
That's exactly what useReducer does! When your component's state becomes complex with multiple related pieces of data that change together, useReducer provides a more organized and predictable way to manage updates.
Let's start by understanding when useState becomes unwieldy. Imagine you're building a shopping cart component:
function ShoppingCart() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [total, setTotal] = useState(0);
const [discountCode, setDiscountCode] = useState('');
const [discountAmount, setDiscountAmount] = useState(0);
const addItem = (product) => {
setLoading(true);
setError(null);
// Simulate API call
setTimeout(() => {
setItems(prev => [...prev, product]);
setTotal(prev => prev + product.price);
setLoading(false);
}, 1000);
};
const removeItem = (productId) => {
setItems(prev => prev.filter(item => item.id !== productId));
const removedItem = items.find(item => item.id === productId);
if (removedItem) {
setTotal(prev => prev - removedItem.price);
}
};
const applyDiscount = (code) => {
setLoading(true);
// Simulate validation
setTimeout(() => {
if (code === 'SAVE10') {
setDiscountCode(code);
setDiscountAmount(total * 0.1);
} else {
setError('Invalid discount code');
}
setLoading(false);
}, 500);
};
// This component is getting complex and error-prone!
}
Problems with this approach:
useReducer is React's solution for managing complex state. Instead of multiple useState calls, you have:
Think of it like this:
Here's the basic syntax:
import React, { useReducer } from 'react';
// 1. Define your initial state
const initialState = {
count: 0,
step: 1
};
// 2. Define your reducer function
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'setStep':
return { ...state, step: action.payload };
case 'reset':
return initialState;
default:
throw new Error('Unknown action type: ' + action.type);
}
}
// 3. Use it in your component
function Counter() {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div>
<h2>Count: {state.count}</h2>
<p>Step size: {state.step}</p>
<button onClick={() => dispatch({ type: 'increment' })}>
+{state.step}
</button>
<button onClick={() => dispatch({ type: 'decrement' })}>
-{state.step}
</button>
<div>
<label>
Step size:
<input
type="number"
value={state.step}
onChange={(e) => dispatch({
type: 'setStep',
payload: Number(e.target.value)
})}
/>
</label>
</div>
<button onClick={() => dispatch({ type: 'reset' })}>
Reset
</button>
</div>
);
}
Key concepts:
type)Let's rebuild our shopping cart using useReducer to see the difference:
// 1. Define initial state
const initialCartState = {
items: [],
total: 0,
loading: false,
error: null,
discountCode: '',
discountAmount: 0
};
// 2. Define reducer function
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM_START':
return {
...state,
loading: true,
error: null
};
case 'ADD_ITEM_SUCCESS':
const newItem = action.payload;
return {
...state,
items: [...state.items, newItem],
total: state.total + newItem.price,
loading: false
};
case 'ADD_ITEM_ERROR':
return {
...state,
loading: false,
error: action.payload
};
case 'REMOVE_ITEM':
const itemToRemove = state.items.find(item => item.id === action.payload);
return {
...state,
items: state.items.filter(item => item.id !== action.payload),
total: state.total - (itemToRemove ? itemToRemove.price : 0)
};
case 'APPLY_DISCOUNT_START':
return {
...state,
loading: true,
error: null
};
case 'APPLY_DISCOUNT_SUCCESS':
return {
...state,
discountCode: action.payload.code,
discountAmount: action.payload.amount,
loading: false
};
case 'APPLY_DISCOUNT_ERROR':
return {
...state,
error: action.payload,
loading: false
};
case 'CLEAR_CART':
return {
...initialCartState
};
default:
throw new Error('Unknown action type: ' + action.type);
}
}
// 3. Use in component
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialCartState);
const addItem = async (product) => {
dispatch({ type: 'ADD_ITEM_START' });
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
dispatch({
type: 'ADD_ITEM_SUCCESS',
payload: { ...product, id: Date.now() }
});
} catch (error) {
dispatch({
type: 'ADD_ITEM_ERROR',
payload: 'Failed to add item to cart'
});
}
};
const removeItem = (itemId) => {
dispatch({ type: 'REMOVE_ITEM', payload: itemId });
};
const applyDiscount = async (code) => {
dispatch({ type: 'APPLY_DISCOUNT_START' });
try {
// Simulate discount validation
await new Promise(resolve => setTimeout(resolve, 500));
if (code === 'SAVE10') {
dispatch({
type: 'APPLY_DISCOUNT_SUCCESS',
payload: {
code: code,
amount: state.total * 0.1
}
});
} else {
throw new Error('Invalid discount code');
}
} catch (error) {
dispatch({
type: 'APPLY_DISCOUNT_ERROR',
payload: error.message
});
}
};
const finalTotal = state.total - state.discountAmount;
return (
<div style={{ padding: '20px', maxWidth: '600px' }}>
<h2>๐ Shopping Cart</h2>
{/* Error display */}
{state.error && (
<div style={{
color: 'red',
backgroundColor: '#ffebee',
padding: '10px',
borderRadius: '5px',
marginBottom: '15px'
}}>
โ {state.error}
</div>
)}
{/* Sample products */}
<div style={{ marginBottom: '20px' }}>
<h3>Available Products</h3>
<button
onClick={() => addItem({ name: 'Cool T-Shirt', price: 19.99 })}
disabled={state.loading}
>
Add T-Shirt ($19.99)
</button>
<button
onClick={() => addItem({ name: 'Nice Shoes', price: 89.99 })}
disabled={state.loading}
>
Add Shoes ($89.99)
</button>
<button
onClick={() => addItem({ name: 'Awesome Hat', price: 24.99 })}
disabled={state.loading}
>
Add Hat ($24.99)
</button>
</div>
{/* Cart items */}
<div style={{ marginBottom: '20px' }}>
<h3>Cart Items ({state.items.length})</h3>
{state.items.length === 0 ? (
<p>Your cart is empty</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{state.items.map(item => (
<li key={item.id} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '10px',
border: '1px solid #ddd',
marginBottom: '5px',
borderRadius: '5px'
}}>
<span>{item.name} - $" + item.price + "</span>
<button onClick={() => removeItem(item.id)}>
Remove
</button>
</li>
))}
</ul>
)}
</div>
{/* Discount section */}
{state.items.length > 0 && (
<div style={{ marginBottom: '20px' }}>
<h4>Apply Discount</h4>
<input
type="text"
placeholder="Enter discount code (try SAVE10)"
value={state.discountCode}
onChange={(e) => {/* We could add SET_DISCOUNT_CODE action */}}
style={{ marginRight: '10px', padding: '5px' }}
/>
<button
onClick={() => applyDiscount('SAVE10')}
disabled={state.loading}
>
Apply Discount
</button>
{state.discountAmount > 0 && (
<div style={{ color: 'green', marginTop: '5px' }}>
โ
Discount applied: -$" + state.discountAmount.toFixed(2) + "
</div>
)}
</div>
)}
{/* Total */}
{state.items.length > 0 && (
<div style={{
backgroundColor: '#f8f9fa',
padding: '15px',
borderRadius: '5px',
marginBottom: '15px'
}}>
<div>Subtotal: $" + state.total.toFixed(2) + "</div>
{state.discountAmount > 0 && (
<div>Discount: -$" + state.discountAmount.toFixed(2) + "</div>
)}
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>
Total: $" + finalTotal.toFixed(2) + "
</div>
</div>
)}
{/* Actions */}
{state.items.length > 0 && (
<div>
<button
style={{
backgroundColor: '#28a745',
color: 'white',
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
marginRight: '10px'
}}
>
Checkout
</button>
<button
onClick={() => dispatch({ type: 'CLEAR_CART' })}
style={{
backgroundColor: '#dc3545',
color: 'white',
padding: '10px 20px',
border: 'none',
borderRadius: '5px'
}}
>
Clear Cart
</button>
</div>
)}
{state.loading && (
<div style={{ textAlign: 'center', marginTop: '15px' }}>
๐ Loading...
</div>
)}
</div>
);
}
Benefits of this approach:
โ
Simple, independent state (a boolean, a string, a number)
โ
State changes are straightforward (toggle, set value)
โ
No complex relationships between state pieces
โ
Component state is small and easy to manage
// Good for useState
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState('');
const [count, setCount] = useState(0);
โ
Complex state object with multiple related properties
โ
State transitions are complex or follow specific rules
โ
Multiple ways to update the same state
โ
You need predictable state management
โ
Logic should be testable outside the component
// Good for useReducer
const [state, dispatch] = useReducer(formReducer, {
values: {},
errors: {},
touched: {},
isSubmitting: false
});
Let's build a complex form with validation using useReducer:
// Form state and reducer
const initialFormState = {
values: {
name: '',
email: '',
password: '',
confirmPassword: ''
},
errors: {},
touched: {},
isSubmitting: false,
isValid: false
};
function formReducer(state, action) {
switch (action.type) {
case 'FIELD_CHANGE':
const newValues = {
...state.values,
[action.field]: action.value
};
// Validate field
const newErrors = { ...state.errors };
const validation = validateField(action.field, action.value, newValues);
if (validation.isValid) {
delete newErrors[action.field];
} else {
newErrors[action.field] = validation.message;
}
return {
...state,
values: newValues,
errors: newErrors,
isValid: Object.keys(newErrors).length === 0
};
case 'FIELD_BLUR':
return {
...state,
touched: {
...state.touched,
[action.field]: true
}
};
case 'SUBMIT_START':
return {
...state,
isSubmitting: true
};
case 'SUBMIT_SUCCESS':
return {
...initialFormState
};
case 'SUBMIT_ERROR':
return {
...state,
isSubmitting: false,
errors: {
...state.errors,
submit: action.payload
}
};
case 'RESET_FORM':
return initialFormState;
default:
throw new Error('Unknown action type: ' + action.type);
}
}
// Validation helper
function validateField(fieldName, value, allValues) {
switch (fieldName) {
case 'name':
if (!value.trim()) {
return { isValid: false, message: 'Name is required' };
}
if (value.length < 2) {
return { isValid: false, message: 'Name must be at least 2 characters' };
}
return { isValid: true };
case 'email':
const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
if (!value) {
return { isValid: false, message: 'Email is required' };
}
if (!emailRegex.test(value)) {
return { isValid: false, message: 'Please enter a valid email' };
}
return { isValid: true };
case 'password':
if (!value) {
return { isValid: false, message: 'Password is required' };
}
if (value.length < 6) {
return { isValid: false, message: 'Password must be at least 6 characters' };
}
return { isValid: true };
case 'confirmPassword':
if (!value) {
return { isValid: false, message: 'Please confirm your password' };
}
if (value !== allValues.password) {
return { isValid: false, message: 'Passwords do not match' };
}
return { isValid: true };
default:
return { isValid: true };
}
}
// Form component
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState);
const handleFieldChange = (field, value) => {
dispatch({
type: 'FIELD_CHANGE',
field,
value
});
};
const handleFieldBlur = (field) => {
dispatch({
type: 'FIELD_BLUR',
field
});
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!state.isValid) {
// Mark all fields as touched to show errors
Object.keys(state.values).forEach(field => {
dispatch({ type: 'FIELD_BLUR', field });
});
return;
}
dispatch({ type: 'SUBMIT_START' });
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
// Simulate random success/failure
if (Math.random() > 0.3) {
dispatch({ type: 'SUBMIT_SUCCESS' });
alert('Registration successful!');
} else {
throw new Error('Server error occurred');
}
} catch (error) {
dispatch({
type: 'SUBMIT_ERROR',
payload: error.message
});
}
};
const getFieldError = (fieldName) => {
return state.touched[fieldName] && state.errors[fieldName];
};
return (
<div style={{ padding: '20px', maxWidth: '400px' }}>
<h2>๐ Registration Form</h2>
<form onSubmit={handleSubmit}>
{/* Name field */}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Name *
</label>
<input
type="text"
value={state.values.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
onBlur={() => handleFieldBlur('name')}
style={{
width: '100%',
padding: '8px',
border: '1px solid ' + (getFieldError('name') ? 'red' : '#ddd'),
borderRadius: '4px'
}}
/>
{getFieldError('name') && (
<div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}>
{getFieldError('name')}
</div>
)}
</div>
{/* Email field */}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Email *
</label>
<input
type="email"
value={state.values.email}
onChange={(e) => handleFieldChange('email', e.target.value)}
onBlur={() => handleFieldBlur('email')}
style={{
width: '100%',
padding: '8px',
border: '1px solid ' + (getFieldError('email') ? 'red' : '#ddd'),
borderRadius: '4px'
}}
/>
{getFieldError('email') && (
<div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}>
{getFieldError('email')}
</div>
)}
</div>
{/* Password field */}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Password *
</label>
<input
type="password"
value={state.values.password}
onChange={(e) => handleFieldChange('password', e.target.value)}
onBlur={() => handleFieldBlur('password')}
style={{
width: '100%',
padding: '8px',
border: '1px solid ' + (getFieldError('password') ? 'red' : '#ddd'),
borderRadius: '4px'
}}
/>
{getFieldError('password') && (
<div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}>
{getFieldError('password')}
</div>
)}
</div>
{/* Confirm Password field */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Confirm Password *
</label>
<input
type="password"
value={state.values.confirmPassword}
onChange={(e) => handleFieldChange('confirmPassword', e.target.value)}
onBlur={() => handleFieldBlur('confirmPassword')}
style={{
width: '100%',
padding: '8px',
border: '1px solid ' + (getFieldError('confirmPassword') ? 'red' : '#ddd'),
borderRadius: '4px'
}}
/>
{getFieldError('confirmPassword') && (
<div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}>
{getFieldError('confirmPassword')}
</div>
)}
</div>
{/* Submit error */}
{state.errors.submit && (
<div style={{
color: 'red',
backgroundColor: '#ffebee',
padding: '10px',
borderRadius: '4px',
marginBottom: '15px'
}}>
โ {state.errors.submit}
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={state.isSubmitting || !state.isValid}
style={{
width: '100%',
padding: '12px',
backgroundColor: state.isValid ? '#28a745' : '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
cursor: state.isValid ? 'pointer' : 'not-allowed'
}}
>
{state.isSubmitting ? 'Creating Account...' : 'Create Account'}
</button>
<button
type="button"
onClick={() => dispatch({ type: 'RESET_FORM' })}
style={{
width: '100%',
padding: '8px',
backgroundColor: 'transparent',
color: '#6c757d',
border: '1px solid #6c757d',
borderRadius: '4px',
marginTop: '10px',
cursor: 'pointer'
}}
>
Reset Form
</button>
</form>
{/* Debug info */}
<details style={{ marginTop: '20px', fontSize: '12px' }}>
<summary>Debug Info</summary>
<pre>{JSON.stringify(state, null, 2)}</pre>
</details>
</div>
);
}
This form demonstrates:
For global state management, you can combine useReducer with useContext:
// Create context for global state
const AppStateContext = createContext();
// App state reducer
const initialAppState = {
user: null,
notifications: [],
theme: 'light',
settings: {
emailNotifications: true,
pushNotifications: false
}
};
function appReducer(state, action) {
switch (action.type) {
case 'LOGIN':
return {
...state,
user: action.payload
};
case 'LOGOUT':
return {
...state,
user: null,
notifications: []
};
case 'ADD_NOTIFICATION':
return {
...state,
notifications: [...state.notifications, action.payload]
};
case 'REMOVE_NOTIFICATION':
return {
...state,
notifications: state.notifications.filter(n => n.id !== action.payload)
};
case 'TOGGLE_THEME':
return {
...state,
theme: state.theme === 'light' ? 'dark' : 'light'
};
case 'UPDATE_SETTINGS':
return {
...state,
settings: {
...state.settings,
...action.payload
}
};
default:
throw new Error('Unknown action type: ' + action.type);
}
}
// Provider component
function AppStateProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialAppState);
return (
<AppStateContext.Provider value={{ state, dispatch }}>
{children}
</AppStateContext.Provider>
);
}
// Custom hook to use app state
function useAppState() {
const context = useContext(AppStateContext);
if (!context) {
throw new Error('useAppState must be used within AppStateProvider');
}
return context;
}
// Component using global state
function UserDashboard() {
const { state, dispatch } = useAppState();
const handleLogin = () => {
dispatch({
type: 'LOGIN',
payload: { id: 1, name: 'John Doe', email: 'john@example.com' }
});
dispatch({
type: 'ADD_NOTIFICATION',
payload: { id: Date.now(), message: 'Welcome back!', type: 'success' }
});
};
const handleLogout = () => {
dispatch({ type: 'LOGOUT' });
};
return (
<div style={{
padding: '20px',
backgroundColor: state.theme === 'dark' ? '#333' : '#fff',
color: state.theme === 'dark' ? '#fff' : '#333',
minHeight: '100vh'
}}>
<h1>Dashboard</h1>
{state.user ? (
<div>
<p>Welcome, {state.user.name}!</p>
<button onClick={handleLogout}>Logout</button>
</div>
) : (
<button onClick={handleLogin}>Login</button>
)}
<button onClick={() => dispatch({ type: 'TOGGLE_THEME' })}>
Switch to {state.theme === 'light' ? 'Dark' : 'Light'} Mode
</button>
{/* Notifications */}
{state.notifications.map(notification => (
<div key={notification.id} style={{
backgroundColor: '#e3f2fd',
padding: '10px',
margin: '10px 0',
borderRadius: '5px'
}}>
{notification.message}
<button onClick={() => dispatch({
type: 'REMOVE_NOTIFICATION',
payload: notification.id
})}>
ร
</button>
</div>
))}
</div>
);
}
// App component
function App() {
return (
<AppStateProvider>
<UserDashboard />
</AppStateProvider>
);
}
Try building a todo app with these features using useReducer:
Requirements:
Starter structure:
const initialTodoState = {
todos: [],
categories: ['Work', 'Personal', 'Shopping'],
filter: 'all', // 'all', 'active', 'completed'
selectedCategory: 'all'
};
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
// Your code here
break;
case 'TOGGLE_TODO':
// Your code here
break;
case 'DELETE_TODO':
// Your code here
break;
case 'SET_FILTER':
// Your code here
break;
// Add more cases...
default:
throw new Error('Unknown action: ' + action.type);
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialTodoState);
// Your component code here
}
// Instead of dispatching objects directly
dispatch({ type: 'ADD_TODO', payload: { text: 'Learn React', category: 'Work' } });
// Create action creator functions
const addTodo = (text, category) => ({
type: 'ADD_TODO',
payload: { text, category }
});
const toggleTodo = (id) => ({
type: 'TOGGLE_TODO',
payload: id
});
// Usage
dispatch(addTodo('Learn React', 'Work'));
dispatch(toggleTodo(123));
function DataComponent() {
const [state, dispatch] = useReducer(dataReducer, initialState);
useEffect(() => {
const fetchData = async () => {
dispatch({ type: 'FETCH_START' });
try {
const data = await api.getData();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
fetchData();
}, []);
return (
<div>
{state.loading && <p>Loading...</p>}
{state.error && <p>Error: {state.error}</p>}
{state.data && <DataDisplay data={state.data} />}
</div>
);
}
function loggingReducer(reducer) {
return (state, action) => {
console.log('Action:', action);
console.log('Previous State:', state);
const newState = reducer(state, action);
console.log('New State:', newState);
return newState;
};
}
// Usage
const [state, dispatch] = useReducer(loggingReducer(myReducer), initialState);
Congratulations! You now understand:
โ
When useReducer is better than useState
โ
How to write reducer functions and actions
โ
Complex state management patterns
โ
Combining useReducer with useContext
โ
Real-world applications like forms and shopping carts
โ
Best practices and common patterns
Test your useReducer knowledge:
Answers: 1) Reducer function, initial state, and optional init function, 2) A type property, 3) When state is complex or state transitions need to be predictable, 4) They don't mutate state and always return the same output for the same inputs
In our next lesson, we'll learn about Custom Hooks - how to extract and reuse stateful logic across components. You'll discover how to create your own hooks that encapsulate complex functionality and make your components cleaner and more reusable.
useReducer provides the foundation for building powerful custom hooks that manage complex state!