Redux Reducers

Understanding Reducers

Reducers are pure functions that take the current state and an action as arguments, and return a new state. They specify how the application's state changes in response to actions sent to the store.

🏦 The Bank Teller Analogy

Think of a reducer as a bank teller:

  • Current Balance: The current state
  • Transaction Slip: The action
  • New Balance: The new state
  • Bank Teller: The reducer function

The teller follows strict rules: they can't change the original balance (immutability), they must process each transaction the same way every time (pure function), and they can only update the balance based on valid transactions.

Reducer Rules

graph TD A[Reducer Function] --> B[Must be Pure] A --> C[Never Mutate State] A --> D[Return New State] A --> E[Handle Unknown Actions] B --> F[Same input = Same output] C --> G[Create new objects/arrays] D --> H[Or return existing state] E --> I[Default case returns state] style A fill:#f96 style B fill:#9cf style C fill:#9f9 style D fill:#ff9 style E fill:#f9f

The Four Golden Rules

  1. Pure Functions: Same inputs always produce same outputs
  2. No Side Effects: Don't modify anything outside the function
  3. Immutable Updates: Never mutate the existing state
  4. Synchronous: No async operations, API calls, or random values

// Good reducer - follows all rules
function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1; // Returns new value
    case 'DECREMENT':
      return state - 1; // Returns new value
    default:
      return state; // Returns existing state for unknown actions
  }
}

// Bad reducer - breaks the rules
function badReducer(state = 0, action) {
  switch (action.type) {
    case 'INCREMENT':
      state++; // ❌ Mutating state!
      return state;
    case 'RANDOM':
      return Math.random(); // ❌ Not pure - random values!
    case 'FETCH_DATA':
      fetch('/api/data'); // ❌ Side effect - API call!
      return state;
    // ❌ Missing default case!
  }
}
            

Immutable State Updates

Why Immutability Matters

Redux uses reference equality to determine if state has changed. If you mutate state directly, Redux won't detect the change and components won't re-render.


// Immutable updates for different data types

// Primitive values (numbers, strings, booleans)
function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1; // New value
    default:
      return state;
  }
}

// Objects
function userReducer(state = {}, action) {
  switch (action.type) {
    case 'UPDATE_NAME':
      return {
        ...state, // Spread existing properties
        name: action.payload // Override specific property
      };
    case 'UPDATE_PROFILE':
      return {
        ...state,
        profile: {
          ...state.profile, // Spread nested object
          ...action.payload // Merge updates
        }
      };
    default:
      return state;
  }
}

// Arrays
function todosReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, action.payload]; // Add to end
      
    case 'REMOVE_TODO':
      return state.filter(todo => todo.id !== action.payload);
      
    case 'UPDATE_TODO':
      return state.map(todo =>
        todo.id === action.payload.id
          ? { ...todo, ...action.payload.updates }
          : todo
      );
      
    case 'INSERT_TODO':
      const { index, todo } = action.payload;
      return [
        ...state.slice(0, index),
        todo,
        ...state.slice(index)
      ];
      
    default:
      return state;
  }
}

// Nested structures
function complexReducer(state = { users: {}, posts: [] }, action) {
  switch (action.type) {
    case 'ADD_USER':
      return {
        ...state,
        users: {
          ...state.users,
          [action.payload.id]: action.payload
        }
      };
      
    case 'ADD_POST':
      return {
        ...state,
        posts: [...state.posts, action.payload]
      };
      
    case 'UPDATE_USER_POST':
      const { userId, postId, updates } = action.payload;
      return {
        ...state,
        users: {
          ...state.users,
          [userId]: {
            ...state.users[userId],
            posts: state.users[userId].posts.map(post =>
              post.id === postId
                ? { ...post, ...updates }
                : post
            )
          }
        }
      };
      
    default:
      return state;
  }
}
            

Combining Reducers

graph TD A[Root Reducer] --> B[User Reducer] A --> C[Posts Reducer] A --> D[Comments Reducer] A --> E[UI Reducer] B --> F[State.user] C --> G[State.posts] D --> H[State.comments] E --> I[State.ui] style A fill:#f96

import { combineReducers } from 'redux';

// Individual reducers
function userReducer(state = null, action) {
  switch (action.type) {
    case 'LOGIN_SUCCESS':
      return action.payload;
    case 'LOGOUT':
      return null;
    default:
      return state;
  }
}

function postsReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_POST':
      return [...state, action.payload];
    case 'DELETE_POST':
      return state.filter(post => post.id !== action.payload);
    default:
      return state;
  }
}

function uiReducer(state = { loading: false, error: null }, action) {
  switch (action.type) {
    case 'SET_LOADING':
      return { ...state, loading: action.payload };
    case 'SET_ERROR':
      return { ...state, error: action.payload };
    default:
      return state;
  }
}

// Combine reducers
const rootReducer = combineReducers({
  user: userReducer,
  posts: postsReducer,
  ui: uiReducer
});

// Resulting state shape:
// {
//   user: null | { id, name, email },
//   posts: [],
//   ui: { loading: false, error: null }
// }

// Manual combination (what combineReducers does internally)
function rootReducerManual(state = {}, action) {
  return {
    user: userReducer(state.user, action),
    posts: postsReducer(state.posts, action),
    ui: uiReducer(state.ui, action)
  };
}
            

Reducer Patterns

Reducer Composition


// Composing reducers for complex state management
function todosReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, todoReducer(undefined, action)];
    case 'TOGGLE_TODO':
    case 'UPDATE_TODO':
      return state.map(todo => todoReducer(todo, action));
    default:
      return state;
  }
}

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        id: action.payload.id,
        text: action.payload.text,
        completed: false
      };
    case 'TOGGLE_TODO':
      if (state.id !== action.payload) {
        return state;
      }
      return {
        ...state,
        completed: !state.completed
      };
    case 'UPDATE_TODO':
      if (state.id !== action.payload.id) {
        return state;
      }
      return {
        ...state,
        ...action.payload.updates
      };
    default:
      return state;
  }
}
            

Higher-Order Reducers


// Create a reducer that handles loading states
function withLoadingState(reducer) {
  return (state, action) => {
    // Handle loading actions
    if (action.type.endsWith('_REQUEST')) {
      return {
        ...state,
        loading: true,
        error: null
      };
    }
    
    if (action.type.endsWith('_SUCCESS')) {
      return {
        ...reducer(state, action),
        loading: false,
        error: null
      };
    }
    
    if (action.type.endsWith('_FAILURE')) {
      return {
        ...state,
        loading: false,
        error: action.payload
      };
    }
    
    // Pass through to original reducer
    return reducer(state, action);
  };
}

// Usage
const enhancedPostsReducer = withLoadingState(postsReducer);

// Reducer factory
function createFilteredReducer(reducerFunction, reducerPredicate) {
  return (state, action) => {
    const shouldRunReducer = reducerPredicate(action);
    return shouldRunReducer ? reducerFunction(state, action) : state;
  };
}

// Create namespace-specific reducer
const userPostsReducer = createFilteredReducer(
  postsReducer,
  action => action.type.startsWith('USER_POSTS_')
);
            

Real-World Example: Shopping Cart Reducer


// Shopping cart state shape
const initialState = {
  items: {},  // { [productId]: { product, quantity } }
  totalItems: 0,
  totalPrice: 0,
  appliedCoupon: null,
  discount: 0
};

function cartReducer(state = initialState, action) {
  switch (action.type) {
    case 'cart/addItem': {
      const { product } = action.payload;
      const existingItem = state.items[product.id];
      
      const updatedItem = existingItem
        ? {
            ...existingItem,
            quantity: existingItem.quantity + 1
          }
        : {
            product,
            quantity: 1
          };
      
      return {
        ...state,
        items: {
          ...state.items,
          [product.id]: updatedItem
        },
        totalItems: state.totalItems + 1,
        totalPrice: state.totalPrice + product.price
      };
    }
    
    case 'cart/removeItem': {
      const { productId } = action.payload;
      const item = state.items[productId];
      
      if (!item) return state;
      
      const { [productId]: removed, ...remainingItems } = state.items;
      
      return {
        ...state,
        items: remainingItems,
        totalItems: state.totalItems - item.quantity,
        totalPrice: state.totalPrice - (item.product.price * item.quantity)
      };
    }
    
    case 'cart/updateQuantity': {
      const { productId, quantity } = action.payload;
      const item = state.items[productId];
      
      if (!item || quantity < 0) return state;
      
      if (quantity === 0) {
        // Remove item if quantity is 0
        return cartReducer(state, {
          type: 'cart/removeItem',
          payload: { productId }
        });
      }
      
      const quantityDiff = quantity - item.quantity;
      
      return {
        ...state,
        items: {
          ...state.items,
          [productId]: {
            ...item,
            quantity
          }
        },
        totalItems: state.totalItems + quantityDiff,
        totalPrice: state.totalPrice + (item.product.price * quantityDiff)
      };
    }
    
    case 'cart/applyCoupon': {
      const { coupon } = action.payload;
      const discount = calculateDiscount(state.totalPrice, coupon);
      
      return {
        ...state,
        appliedCoupon: coupon,
        discount
      };
    }
    
    case 'cart/clear':
      return initialState;
    
    default:
      return state;
  }
}

// Helper function
function calculateDiscount(totalPrice, coupon) {
  if (!coupon) return 0;
  
  switch (coupon.type) {
    case 'PERCENTAGE':
      return totalPrice * (coupon.value / 100);
    case 'FIXED':
      return Math.min(coupon.value, totalPrice);
    default:
      return 0;
  }
}

// Selectors (derived state)
const selectCartTotal = (state) => 
  state.cart.totalPrice - state.cart.discount;

const selectCartItemCount = (state) => 
  state.cart.totalItems;

const selectCartItems = (state) => 
  Object.values(state.cart.items);

const selectIsCartEmpty = (state) => 
  state.cart.totalItems === 0;
            

Testing Reducers


// Testing reducers is straightforward since they're pure functions
describe('cartReducer', () => {
  it('should return the initial state', () => {
    expect(cartReducer(undefined, {})).toEqual(initialState);
  });
  
  it('should handle cart/addItem', () => {
    const product = { id: 1, name: 'Book', price: 10 };
    const action = {
      type: 'cart/addItem',
      payload: { product }
    };
    
    const expectedState = {
      ...initialState,
      items: {
        1: { product, quantity: 1 }
      },
      totalItems: 1,
      totalPrice: 10
    };
    
    expect(cartReducer(initialState, action)).toEqual(expectedState);
  });
  
  it('should handle adding existing item', () => {
    const product = { id: 1, name: 'Book', price: 10 };
    const stateWithItem = {
      ...initialState,
      items: {
        1: { product, quantity: 1 }
      },
      totalItems: 1,
      totalPrice: 10
    };
    
    const action = {
      type: 'cart/addItem',
      payload: { product }
    };
    
    const expectedState = {
      ...stateWithItem,
      items: {
        1: { product, quantity: 2 }
      },
      totalItems: 2,
      totalPrice: 20
    };
    
    expect(cartReducer(stateWithItem, action)).toEqual(expectedState);
  });
  
  it('should handle cart/updateQuantity', () => {
    const product = { id: 1, name: 'Book', price: 10 };
    const stateWithItem = {
      ...initialState,
      items: {
        1: { product, quantity: 2 }
      },
      totalItems: 2,
      totalPrice: 20
    };
    
    const action = {
      type: 'cart/updateQuantity',
      payload: { productId: 1, quantity: 5 }
    };
    
    const expectedState = {
      ...stateWithItem,
      items: {
        1: { product, quantity: 5 }
      },
      totalItems: 5,
      totalPrice: 50
    };
    
    expect(cartReducer(stateWithItem, action)).toEqual(expectedState);
  });
});

// Test helper for reducer testing
function createReducerTest(reducer, initialState) {
  return (action, expectedState, previousState = initialState) => {
    const newState = reducer(previousState, action);
    expect(newState).toEqual(expectedState);
    // Ensure immutability
    expect(newState).not.toBe(previousState);
  };
}

// Usage
const testCartReducer = createReducerTest(cartReducer, initialState);

testCartReducer(
  { type: 'cart/clear' },
  initialState,
  { ...initialState, totalItems: 5 }
);
            

Common Reducer Patterns and Anti-patterns

Good Patterns


// Normalized state
const normalizedState = {
  entities: {
    users: {
      '1': { id: '1', name: 'John' },
      '2': { id: '2', name: 'Jane' }
    },
    posts: {
      '101': { id: '101', title: 'Redux', authorId: '1' }
    }
  },
  ids: {
    users: ['1', '2'],
    posts: ['101']
  }
};

// Reducer with normalized state
function entitiesReducer(state = { users: {}, posts: {} }, action) {
  switch (action.type) {
    case 'ADD_ENTITIES':
      return {
        users: { ...state.users, ...action.payload.users },
        posts: { ...state.posts, ...action.payload.posts }
      };
    default:
      return state;
  }
}

// Using immer for complex updates (Redux Toolkit)
import produce from 'immer';

const todosReducer = produce((draft, action) => {
  switch (action.type) {
    case 'ADD_TODO':
      draft.push(action.payload);
      break;
    case 'TOGGLE_TODO':
      const todo = draft.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
      break;
  }
}, []);
                

Anti-patterns to Avoid


// ❌ Don't mutate state
function badReducer(state, action) {
  state.value = action.payload; // Direct mutation!
  return state;
}

// ❌ Don't perform side effects
function badReducer(state, action) {
  if (action.type === 'SAVE_DATA') {
    localStorage.setItem('data', JSON.stringify(state)); // Side effect!
  }
  return state;
}

// ❌ Don't use non-deterministic values
function badReducer(state, action) {
  return {
    ...state,
    timestamp: Date.now(), // Non-deterministic!
    id: Math.random() // Non-deterministic!
  };
}

// ❌ Don't forget default case
function badReducer(state = initialState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    // Missing default case!
  }
}
                

Practice Exercise

Task: Create a Form Reducer

Build a reducer that manages a complex form state with the following requirements:

  • Handle multiple form fields
  • Track field validation errors
  • Manage form submission state
  • Reset form functionality

// Initial state structure
const initialFormState = {
  values: {
    username: '',
    email: '',
    password: ''
  },
  errors: {
    username: null,
    email: null,
    password: null
  },
  touched: {
    username: false,
    email: false,
    password: false
  },
  isSubmitting: false,
  submitError: null
};

// TODO: Implement the form reducer
function formReducer(state = initialFormState, action) {
  switch (action.type) {
    // Handle field changes
    case 'form/updateField':
      // Your implementation
      
    // Handle field blur (mark as touched)
    case 'form/blurField':
      // Your implementation
      
    // Handle validation errors
    case 'form/setFieldError':
      // Your implementation
      
    // Handle form submission
    case 'form/submitStart':
      // Your implementation
      
    case 'form/submitSuccess':
      // Your implementation
      
    case 'form/submitError':
      // Your implementation
      
    // Reset form
    case 'form/reset':
      // Your implementation
      
    default:
      return state;
  }
}

// Example actions to handle:
// { type: 'form/updateField', payload: { field: 'username', value: 'john' } }
// { type: 'form/setFieldError', payload: { field: 'email', error: 'Invalid email' } }
                

Additional Resources