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

Loading

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:

  1. React uses reference comparison to detect state changes
  2. Mutating state directly won’t trigger re-renders
  3. Missing returns lead to undefined state
  4. 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

  1. Direct state mutation:
   state.value = action.value;
   return state; // ❌
  1. Array mutations:
   state.items.splice(index, 1); // ❌
   return state;
  1. Missing returns:
   case 'update':
     // ❌ No return
     break;
  1. Partial updates:
   return { count: state.count + 1 }; // ❌ Loses other state

Best Practices

  1. Treat state as immutable – Always create new objects/arrays
  2. Use spread operator for shallow updates
  3. Handle all action types – Include default case
  4. Consider Immer for complex state shapes
  5. 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
}

Leave a Reply

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