Welcome Back! Time for useReducer
Imagine you're managing a bank account. You can't just change the balance directly - you need to perform specific actions like "deposit", "withdraw", or "transfer". Each action follows strict rules and creates a record. That's exactly what useReducer does for your React state!
Understanding useReducer
useReducer is like useState's more disciplined cousin. Instead of directly setting state, you dispatch actions that describe what happened, and a reducer function decides how the state should change.
The Anatomy of useReducer
// Basic structure
const [state, dispatch] = useReducer(reducer, initialState);
// Where:
// - state: current state value
// - dispatch: function to send actions
// - reducer: function that handles state updates
// - initialState: starting state value
Your First Reducer
// Counter with useReducer
const initialState = { count: 0 };
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:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<h2>Count: {state.count}</h2>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
+
</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>
-
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>
Reset
</button>
</div>
);
}
useState vs useReducer
When should you use each? Let's compare:
| useState | useReducer |
|---|---|
| Simple state (single values) | Complex state (objects, arrays) |
| Independent state updates | State updates depend on previous state |
| Few state transitions | Many state transitions |
| Local component state | State shared across components |
Example: Todo List Comparison
// With useState - gets messy quickly
function TodosWithState() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const [sortBy, setSortBy] = useState('date');
const [searchTerm, setSearchTerm] = useState('');
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// More functions for filter, sort, search...
}
// With useReducer - clean and organized
const initialState = {
todos: [],
filter: 'all',
sortBy: 'date',
searchTerm: ''
};
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, {
id: Date.now(),
text: action.payload,
completed: false
}]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'SET_SORT':
return { ...state, sortBy: action.payload };
case 'SET_SEARCH':
return { ...state, searchTerm: action.payload };
default:
return state;
}
}
function TodosWithReducer() {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<div>
{/* Clean component with just dispatches */}
<button onClick={() => dispatch({
type: 'ADD_TODO',
payload: 'New todo'
})}>
Add Todo
</button>
{/* Rest of the UI */}
</div>
);
}
Advanced useReducer Patterns
Pattern 1: Action Creators
// Action creators make your code more maintainable
const todoActions = {
addTodo: (text) => ({
type: 'ADD_TODO',
payload: text
}),
toggleTodo: (id) => ({
type: 'TOGGLE_TODO',
payload: id
}),
deleteTodo: (id) => ({
type: 'DELETE_TODO',
payload: id
}),
setFilter: (filter) => ({
type: 'SET_FILTER',
payload: filter
})
};
// Usage
dispatch(todoActions.addTodo('Learn useReducer'));
Pattern 2: Async Actions with useReducer
// Handling async operations
function asyncReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return {
...state,
loading: false,
data: action.payload
};
case 'FETCH_ERROR':
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
}
function DataFetcher() {
const [state, dispatch] = useReducer(asyncReducer, {
data: null,
loading: false,
error: null
});
const fetchData = async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch('/api/data');
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
return (
<div>
{state.loading && <LoadingSpinner />}
{state.error && <ErrorMessage error={state.error} />}
{state.data && <DataDisplay data={state.data} />}
<button onClick={fetchData}>Fetch Data</button>
</div>
);
}
Pattern 3: Computed State
// Deriving values from state
function shoppingReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload]
};
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
case 'UPDATE_QUANTITY':
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
)
};
default:
return state;
}
}
function ShoppingCart() {
const [state, dispatch] = useReducer(shoppingReducer, { items: [] });
// Computed values
const totalItems = state.items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = state.items.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
return (
<div>
<h2>Shopping Cart ({totalItems} items)</h2>
<h3>Total: ${totalPrice.toFixed(2)}</h3>
{/* Cart items display */}
</div>
);
}
Real-World Example: Form Management
Let's build a complex form with validation, multiple steps, and error handling:
// Multi-step form with useReducer
const initialFormState = {
currentStep: 1,
formData: {
personalInfo: {
firstName: '',
lastName: '',
email: ''
},
addressInfo: {
street: '',
city: '',
zipCode: ''
},
paymentInfo: {
cardNumber: '',
expiry: '',
cvv: ''
}
},
errors: {},
isSubmitting: false,
isComplete: false
};
function formReducer(state, action) {
switch (action.type) {
case 'UPDATE_FIELD':
const { section, field, value } = action.payload;
return {
...state,
formData: {
...state.formData,
[section]: {
...state.formData[section],
[field]: value
}
}
};
case 'SET_ERROR':
return {
...state,
errors: {
...state.errors,
[action.payload.field]: action.payload.message
}
};
case 'CLEAR_ERRORS':
return {
...state,
errors: {}
};
case 'NEXT_STEP':
return {
...state,
currentStep: state.currentStep + 1
};
case 'PREV_STEP':
return {
...state,
currentStep: state.currentStep - 1
};
case 'SUBMIT_START':
return {
...state,
isSubmitting: true
};
case 'SUBMIT_SUCCESS':
return {
...state,
isSubmitting: false,
isComplete: true
};
case 'SUBMIT_ERROR':
return {
...state,
isSubmitting: false,
errors: action.payload
};
default:
return state;
}
}
function MultiStepForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState);
const handleFieldChange = (section, field, value) => {
dispatch({
type: 'UPDATE_FIELD',
payload: { section, field, value }
});
};
const validateStep = (step) => {
// Validation logic here
return true; // or false with errors
};
const handleNext = () => {
if (validateStep(state.currentStep)) {
dispatch({ type: 'NEXT_STEP' });
}
};
const handleSubmit = async () => {
dispatch({ type: 'SUBMIT_START' });
try {
const response = await submitForm(state.formData);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (error) {
dispatch({
type: 'SUBMIT_ERROR',
payload: error.validationErrors
});
}
};
return (
<div className="multi-step-form">
<ProgressIndicator currentStep={state.currentStep} />
{state.currentStep === 1 && (
<PersonalInfoStep
data={state.formData.personalInfo}
errors={state.errors}
onChange={handleFieldChange}
/>
)}
{state.currentStep === 2 && (
<AddressStep
data={state.formData.addressInfo}
errors={state.errors}
onChange={handleFieldChange}
/>
)}
{state.currentStep === 3 && (
<PaymentStep
data={state.formData.paymentInfo}
errors={state.errors}
onChange={handleFieldChange}
/>
)}
<div className="form-navigation">
{state.currentStep > 1 && (
<button onClick={() => dispatch({ type: 'PREV_STEP' })}>
Previous
</button>
)}
{state.currentStep < 3 ? (
<button onClick={handleNext} >Next</button>
) : (
<button
onClick={handleSubmit}
disabled={state.isSubmitting}
>
{state.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
</div>
</div>
);
}
Combining useReducer with useContext
The ultimate power move: combining useReducer with useContext for app-wide state management!
// Create a powerful state management system
const AppStateContext = createContext();
const AppDispatchContext = createContext();
function appReducer(state, action) {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
case 'SET_THEME':
return { ...state, theme: action.payload };
case 'ADD_NOTIFICATION':
return {
...state,
notifications: [...state.notifications, action.payload]
};
// More actions...
default:
return state;
}
}
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, {
user: null,
theme: 'light',
notifications: []
});
return (
<AppStateContext.Provider value={state}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
);
}
// Custom hooks for easy access
function useAppState() {
const context = useContext(AppStateContext);
if (!context) {
throw new Error('useAppState must be used within AppProvider');
}
return context;
}
function useAppDispatch() {
const context = useContext(AppDispatchContext);
if (!context) {
throw new Error('useAppDispatch must be used within AppProvider');
}
return context;
}
// Usage in components
function UserProfile() {
const { user } = useAppState();
const dispatch = useAppDispatch();
const handleLogout = () => {
dispatch({ type: 'SET_USER', payload: null });
};
return (
<div>
<h2>Welcome, {user.name}!</h2>
<button onClick={handleLogout}>Logout</button>
</div>
);
}
Practice Exercises
Exercise 1: Game State Manager
Create a reducer to manage a simple game state with the following actions:
- START_GAME
- PAUSE_GAME
- UPDATE_SCORE
- NEXT_LEVEL
- GAME_OVER
// Your task: Create the game reducer
const initialGameState = {
isPlaying: false,
isPaused: false,
score: 0,
level: 1,
lives: 3
};
function gameReducer(state, action) {
// Implement the reducer logic
}
Exercise 2: Shopping Cart with Discounts
Extend a shopping cart reducer to handle:
- Product quantity limits
- Discount codes
- Shipping calculations
Exercise 3: Undo/Redo Functionality
Create a reducer that supports undo and redo operations for a text editor.
// Hint: You'll need to track history
const initialState = {
past: [],
present: '',
future: []
};
Best Practices
1. Keep Reducers Pure
// ❌ Bad: Mutating state
function badReducer(state, action) {
state.count += 1; // Never mutate!
return state;
}
// ✅ Good: Return new state
function goodReducer(state, action) {
return { ...state, count: state.count + 1 };
}
2. Type Your Actions
// Use constants for action types
const ActionTypes = {
ADD_TODO: 'ADD_TODO',
REMOVE_TODO: 'REMOVE_TODO',
TOGGLE_TODO: 'TOGGLE_TODO'
};
// Even better with TypeScript
type Action =
| { type: 'ADD_TODO'; payload: string }
| { type: 'REMOVE_TODO'; payload: number }
| { type: 'TOGGLE_TODO'; payload: number };
3. Handle Unknown Actions
function reducer(state, action) {
switch (action.type) {
// ... cases
default:
console.warn(`Unknown action type: ${action.type}`);
return state; // Always return current state
}
}
Key Takeaways
- useReducer is perfect for complex state logic
- Actions describe what happened, reducers decide how state changes
- Always keep reducers pure - no side effects!
- Combine with useContext for powerful state management
- Use action creators to keep your code organized
When to Use useReducer
- State logic is complex with many sub-values
- Next state depends on the previous one
- You want to optimize performance for deep updates
- You need to share state logic between components