Forgetting to Return New State in useReducer
A common mistake when using useReducer
is mutating the existing state or forgetting to return a new state object from the reducer function, which can lead to unexpected behavior and rendering issues.
The Problem
// ❌ Wrong - Mutates current state
function reducer(state, action) {
switch (action.type) {
case 'increment':
state.count++; // Direct mutation!
return state; // Returns same reference
case 'add_item':
state.items.push(action.item); // Mutates array
return state;
// ❌ Forgetting return for some cases
case 'reset':
return { count: 0, items: [] };
}
}
Why this is problematic:
- React uses reference comparison to detect state changes
- Mutating state directly won’t trigger re-renders
- Missing returns lead to undefined state
- Violates React’s immutability principles
Correct Solutions
1. Always Return New State (Recommended)
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 }; // ✅ New object
case 'add_item':
return {
...state,
items: [...state.items, action.item] // ✅ New array
};
case 'reset':
return { count: 0, items: [] }; // ✅ New state
default:
return state; // ✅ Always have default return
}
}
2. For Complex State
function reducer(state, action) {
switch (action.type) {
case 'update_user':
return {
...state,
user: {
...state.user,
[action.field]: action.value // ✅ Nested update
}
};
case 'delete_item':
return {
...state,
items: state.items.filter(item => item.id !== action.id) // ✅ New filtered array
};
}
}
3. Using Immer for Simplicity
import produce from 'immer';
function reducer(state, action) {
return produce(state, draft => {
switch (action.type) {
case 'increment':
draft.count++; // ✅ Safe mutation with Immer
break;
case 'add_item':
draft.items.push(action.item);
break;
}
});
}
Common Mistakes to Avoid
- Direct state mutation:
state.value = action.value;
return state; // ❌
- Array mutations:
state.items.splice(index, 1); // ❌
return state;
- Missing returns:
case 'update':
// ❌ No return
break;
- Partial updates:
return { count: state.count + 1 }; // ❌ Loses other state
Best Practices
- Treat state as immutable – Always create new objects/arrays
- Use spread operator for shallow updates
- Handle all action types – Include default case
- Consider Immer for complex state shapes
- Test reducers in isolation
Advanced Patterns
1. Action Creators
function increment() {
return { type: 'increment' };
}
function addItem(item) {
return { type: 'add_item', item };
}
// Usage
dispatch(increment());
dispatch(addItem({ id: 1, name: 'Item' }));
2. Reducer Composition
function todosReducer(state, action) {
switch (action.type) {
// Todo-specific cases
}
}
function rootReducer(state, action) {
return {
todos: todosReducer(state.todos, action),
visibility: visibilityFilter(state.visibility, action)
};
}
3. TypeScript Reducers
type Action =
| { type: 'increment' }
| { type: 'add_item'; item: Item };
function reducer(state: State, action: Action): State {
// Type-safe implementation
}