useReducer Hook - Complex State Management
Understanding useReducer
The useReducer hook is an alternative to useState for managing complex state logic. It's similar to how Redux works - you dispatch actions to a reducer function, which calculates the next state based on the current state and action.
useReducer is particularly useful when you have complex state logic involving multiple sub-values, when the next state depends on the previous one, or when you want to optimize performance for components that trigger deep updates.
When to Use useReducer
Use useReducer when: state updates involve complex logic, multiple related state values change together, next state depends on previous state, you need predictable state transitions, or you want to separate state logic from component code.
Basic useReducer Syntax
The useReducer hook takes three arguments: a reducer function, an initial state, and an optional init function. It returns the current state and a dispatch function.
import React, { useReducer } from 'react';
// Reducer function: (currentState, 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 };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<h2>Count: {state.count}</h2>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
+1
</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>
-1
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>
Reset
</button>
</div>
);
}
export default Counter;
Actions with Payloads
Actions can carry additional data (payload) to the reducer:
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'ADD':
return { count: state.count + action.payload };
case 'SUBTRACT':
return { count: state.count - action.payload };
case 'SET':
return { count: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<h2>Count: {state.count}</h2>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
+1
</button>
<button onClick={() => dispatch({ type: 'ADD', payload: 5 })}>
+5
</button>
<button onClick={() => dispatch({ type: 'ADD', payload: 10 })}>
+10
</button>
<button onClick={() => dispatch({ type: 'SET', payload: 0 })}>
Reset
</button>
</div>
);
}
Reducer Best Practices
Always return a new state object (don't mutate), use descriptive action types (constants), handle all cases or return current state in default, and keep reducers pure (no side effects).
Complex State Example: Todo List
Managing a todo list with multiple operations:
import React, { useReducer, useState } from 'react';
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 'EDIT_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, text: action.payload.text }
: todo
)
};
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter(todo => !todo.completed)
};
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, { todos: [] });
const [inputValue, setInputValue] = useState('');
const addTodo = (e) => {
e.preventDefault();
if (inputValue.trim()) {
dispatch({ type: 'ADD_TODO', payload: inputValue });
setInputValue('');
}
};
return (
<div>
<h2>Todo List</h2>
<form onSubmit={addTodo}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Add a todo..."
/>
<button type="submit">Add</button>
</form>
<ul>
{state.todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}>
Delete
</button>
</li>
))}
</ul>
<button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>
Clear Completed
</button>
</div>
);
}
export default TodoApp;
useReducer vs useState
Choosing between useReducer and useState:
// useState - Simple state
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// useReducer - Complex state with multiple operations
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
Common Mistake: Mutating State
Never mutate state directly in a reducer. Always return a new state object. Use spread operators or methods like map() and filter() that return new arrays.
Lazy Initialization
Use an init function for expensive initial state calculations:
function init(initialCount) {
// Expensive calculation
return { count: initialCount * 2 };
}
function Counter({ initialCount }) {
const [state, dispatch] = useReducer(reducer, initialCount, init);
// The init function is only called once on mount
return <div>Count: {state.count}</div>;
}
Form Management with useReducer
Managing complex form state:
import React, { useReducer } from 'react';
function formReducer(state, action) {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
values: {
...state.values,
[action.field]: action.value
}
};
case 'SET_ERROR':
return {
...state,
errors: {
...state.errors,
[action.field]: action.error
}
};
case 'SET_SUBMITTING':
return {
...state,
isSubmitting: action.value
};
case 'RESET_FORM':
return action.payload;
default:
return state;
}
}
function RegistrationForm() {
const initialState = {
values: {
username: '',
email: '',
password: ''
},
errors: {},
isSubmitting: false
};
const [state, dispatch] = useReducer(formReducer, initialState);
const handleChange = (field) => (e) => {
dispatch({
type: 'SET_FIELD',
field,
value: e.target.value
});
};
const validate = () => {
const newErrors = {};
if (!state.values.username) {
newErrors.username = 'Username is required';
}
if (!state.values.email.includes('@')) {
newErrors.email = 'Invalid email';
}
if (state.values.password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
return newErrors;
};
const handleSubmit = async (e) => {
e.preventDefault();
const errors = validate();
if (Object.keys(errors).length > 0) {
Object.entries(errors).forEach(([field, error]) => {
dispatch({ type: 'SET_ERROR', field, error });
});
return;
}
dispatch({ type: 'SET_SUBMITTING', value: true });
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Form submitted:', state.values);
dispatch({ type: 'RESET_FORM', payload: initialState });
} catch (error) {
console.error('Submit error:', error);
} finally {
dispatch({ type: 'SET_SUBMITTING', value: false });
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Username:</label>
<input
type="text"
value={state.values.username}
onChange={handleChange('username')}
/>
{state.errors.username && <span>{state.errors.username}</span>}
</div>
<div>
<label>Email:</label>
<input
type="email"
value={state.values.email}
onChange={handleChange('email')}
/>
{state.errors.email && <span>{state.errors.email}</span>}
</div>
<div>
<label>Password:</label>
<input
type="password"
value={state.values.password}
onChange={handleChange('password')}
/>
{state.errors.password && <span>{state.errors.password}</span>}
</div>
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? 'Submitting...' : 'Register'}
</button>
</form>
);
}
export default RegistrationForm;
Combining useReducer with Context
Create a global state management solution by combining useReducer with Context:
import React, { createContext, useContext, useReducer } from 'react';
// Actions
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
const RESET = 'RESET';
// Reducer
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 };
default:
return state;
}
}
// Context
const CounterContext = createContext();
// Provider
export function CounterProvider({ children }) {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<CounterContext.Provider value={{ state, dispatch }}>
{children}
</CounterContext.Provider>
);
}
// Custom hook
export function useCounter() {
const context = useContext(CounterContext);
if (!context) {
throw new Error('useCounter must be used within CounterProvider');
}
return context;
}
// Usage in components
function CounterDisplay() {
const { state } = useCounter();
return <h2>Count: {state.count}</h2>;
}
function CounterButtons() {
const { dispatch } = useCounter();
return (
<div>
<button onClick={() => dispatch({ type: INCREMENT })}>+</button>
<button onClick={() => dispatch({ type: DECREMENT })}>-</button>
<button onClick={() => dispatch({ type: RESET })}>Reset</button>
</div>
);
}
function App() {
return (
<CounterProvider>
<CounterDisplay />
<CounterButtons />
</CounterProvider>
);
}
Multiple Reducers Pattern
Use multiple reducers for different parts of your application:
function App() {
const [userState, userDispatch] = useReducer(userReducer, initialUserState);
const [cartState, cartDispatch] = useReducer(cartReducer, initialCartState);
const [uiState, uiDispatch] = useReducer(uiReducer, initialUIState);
return (
<UserContext.Provider value={{ userState, userDispatch }}>
<CartContext.Provider value={{ cartState, cartDispatch }}>
<UIContext.Provider value={{ uiState, uiDispatch }}>
<AppContent />
</UIContext.Provider>
</CartContext.Provider>
</UserContext.Provider>
);
}
Action Creators Pattern
Create helper functions to generate actions:
// Action creators
const actions = {
addTodo: (text) => ({ type: 'ADD_TODO', payload: text }),
toggleTodo: (id) => ({ type: 'TOGGLE_TODO', payload: id }),
deleteTodo: (id) => ({ type: 'DELETE_TODO', payload: id }),
editTodo: (id, text) => ({ type: 'EDIT_TODO', payload: { id, text } })
};
// Usage
function TodoList() {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<div>
<button onClick={() => dispatch(actions.addTodo('New task'))}>
Add Todo
</button>
{state.todos.map(todo => (
<div key={todo.id}>
<span onClick={() => dispatch(actions.toggleTodo(todo.id))}>
{todo.text}
</span>
<button onClick={() => dispatch(actions.deleteTodo(todo.id))}>
Delete
</button>
</div>
))}
</div>
);
}
Exercise 1: Shopping Cart
Build a shopping cart using useReducer that supports adding, removing, and updating quantities.
// Requirements:
// 1. Add item to cart (or increment if exists)
// 2. Remove item from cart
// 3. Update item quantity
// 4. Clear entire cart
// 5. Display total price and item count
Exercise 2: Multi-Step Form
Create a multi-step registration form with useReducer managing form state and navigation.
// Requirements:
// 1. Three steps: Personal Info, Account Details, Confirmation
// 2. Next/Previous/Submit buttons
// 3. Validate each step before proceeding
// 4. Show errors per field
// 5. Display summary on confirmation step
Exercise 3: Quiz Application
Build a quiz app with useReducer managing questions, answers, score, and navigation.
// Requirements:
// 1. Display one question at a time
// 2. Track selected answers
// 3. Calculate score
// 4. Show results at the end
// 5. Reset quiz functionality
Summary
useReducermanages complex state with predictable updates via actions- Reducer function:
(state, action) => newState - Use for complex state logic, multiple related values, or when next state depends on previous
- Actions can carry payloads for additional data
- Always return new state objects (immutable updates)
- Combine with Context for global state management
- Use action creators for cleaner dispatch calls
- Lazy initialization for expensive initial state calculations