Using useReducer but forgetting to return a new state in the reducer function

Loading

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:

  1. Direct state mutation violates React’s principles
  2. React may not detect the state change
  3. Components won’t re-render as expected
  4. 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

  1. Never mutate arguments – Always treat state as immutable
  2. Return new objects for changed state
  3. Keep reducers pure – No side effects
  4. Use TypeScript to catch mutation errors
  5. 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

  1. Console.log the state before and after dispatch
  2. Use React DevTools to inspect state changes
  3. Implement action logging in your reducer
  4. Add TypeScript to catch mutation attempts

Leave a Reply

Your email address will not be published. Required fields are marked *