A common mistake when using useReducer
in React is forgetting to return a new state object from the reducer function, which can lead to unexpected behavior and rendering issues.
The Core Problem
In Redux-style reducers, you must always return a new state object rather than mutating the existing state. React relies on this immutability to detect changes and trigger re-renders.
❌ Incorrect Implementation (Mutation)
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
state.count++; // ❌ Mutates existing state
return state; // ❌ Returns same reference
default:
return state;
}
};
Why this is bad:
- Direct state mutation violates React’s principles
- React may not detect the state change
- Components won’t re-render as expected
- Time-travel debugging breaks
Correct Patterns
✅ Proper Immutable Update
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return { // ✅ Returns new object
...state, // ✅ Copies existing state
count: state.count + 1 // ✅ Updates property
};
default:
return state;
}
};
✅ Using Object Spread Operator
For more complex state:
case 'updateUser':
return {
...state,
user: {
...state.user,
name: action.payload.name,
age: action.payload.age
}
};
✅ Using Libraries for Immutability
For complex state structures:
import produce from 'immer';
const reducer = (state, action) => {
return produce(state, draft => {
switch (action.type) {
case 'increment':
draft.count++; // ✅ Immer handles immutability
break;
}
});
};
Common Pitfalls and Solutions
❌ Forgetting to Return State
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
// ❌ No default return
}
};
✅ Always include default case:
default:
return state;
❌ Partial State Updates
case 'updateSettings':
return { theme: action.theme }; // ❌ Loses other state
✅ Merge with existing state:
case 'updateSettings':
return { ...state, theme: action.theme };
Best Practices
- Never mutate arguments – Always treat state as immutable
- Return new objects for changed state
- Keep reducers pure – No side effects
- Use TypeScript to catch mutation errors
- Consider Immer for complex state updates
Real-World Example
Todo List Reducer
const todoReducer = (state, action) => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, action.payload]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.id)
};
default:
return state;
}
};
Debugging Tips
- Console.log the state before and after dispatch
- Use React DevTools to inspect state changes
- Implement action logging in your reducer
- Add TypeScript to catch mutation attempts