Understanding Redux Reducers

The State Transformation Experts of Redux

Introduction to Redux Reducers

In the Redux ecosystem, reducers are pure functions responsible for state transformations. Think of reducers as chefs in a kitchen - they take ingredients (current state and an action) and follow a recipe to create a new dish (the next state).

Reducers are the only entities in Redux that can update the store's state. They receive the current state and an action, then determine how to transform the state based on the action's type.

Reducer Structure

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'ACTION_TYPE_1':
      return newState1;
    case 'ACTION_TYPE_2':
      return newState2;
    default:
      return state;
  }
};

Every reducer follows this essential pattern: it takes the current state and an action as parameters, uses a switch statement to handle different action types, and returns either a modified state or the original state if the action type isn't relevant.

Anatomy of a Reducer

Let's examine a simple reducer function that manages an array of fruits:

Basic Fruit Reducer

const fruitReducer = (state = [], action) => {
  switch (action.type) {
    case 'ADD_FRUIT':
      return [...state, action.fruit];
    default:
      return state;
  }
};

This reducer has two key components:

  1. Default State Parameter: The state = [] syntax provides a default value (an empty array) that becomes the initial state when the store is created.
  2. Switch Statement: The reducer uses different case clauses to handle different action types. If the action type is 'ADD_FRUIT', it creates a new array with the new fruit added. For any unrecognized action, it returns the state unchanged.

Store Initialization: When a Redux store is first created, it calls its reducer with an undefined state, triggering the default parameter to set the initial state.

Handling Multiple Action Types

A real-world application needs to handle many different actions. Let's expand our fruit reducer to handle multiple action types:

Expanded Fruit Reducer

const fruitReducer = (state = [], action) => {
  switch (action.type) {
    case 'ADD_FRUIT':
      return [...state, action.fruit];
    case 'ADD_FRUITS':
      return [...state, ...action.fruits];
    case 'SELL_FRUIT': {
      // find the index of the first instance of action.fruit
      const index = state.indexOf(action.fruit);
      if (index !== -1) {
        // remove first instance of action.fruit using the index and return a new array
        const newState = [...state];
        newState.splice(index, 1);
        return newState;
      }
      return state; // if action.fruit is not in state, return previous state
    }
    case 'SELL_OUT':
      return [];
    default:
      return state;
  }
};

This expanded reducer now handles four different action types:

Block Scoping: Notice the curly braces { } around the 'SELL_FRUIT' case. These create a block scope for the variables declared within that case, preventing naming conflicts with other cases.

The Golden Rule: Never Mutate State

The most critical rule in Redux: Reducers must never mutate their arguments (state and action). Instead, they must create and return new objects when the state changes.

Library Book Analogy

Think of Redux state as a library book. You can't write notes directly in a library book (mutate it). Instead, you must make a photocopy (create a new object) and write your notes on the copy.

Bad Reducer (Mutates State)

const badReducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT_COUNTER':
      state.count++; // ❌ Directly modifies state!
      return state;
    default:
      return state;
  }
};

Good Reducer (Creates New State)

const goodReducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT_COUNTER': {
      const nextState = Object.assign({}, state); // Create a copy
      nextState.count++;
      return nextState;
    }
    default:
      return state;
  }
};

There are several ways to create copies of objects and arrays in JavaScript:

Object Copy Methods

// Using Object.assign
const nextState = Object.assign({}, state, { count: state.count + 1 });

// Using spread operator (ES6+)
const nextState = { ...state, count: state.count + 1 };

Array Copy Methods

// Using spread operator
const newArray = [...oldArray, newItem];

// Using concat
const newArray = oldArray.concat(newItem);

// Using slice to remove items
const newArray = [...oldArray.slice(0, index), ...oldArray.slice(index + 1)];

Why avoid mutations? Immutability makes state changes predictable and traceable, enables features like time-travel debugging, and helps prevent hard-to-find bugs caused by unintended side effects.

Managing Complex State with Multiple Reducers

As applications grow, keeping all state logic in a single reducer becomes unwieldy. Just as we divide our codebase into separate files and functions, we can split our reducer into multiple smaller reducers that each handle a specific slice of state.

Complex State Structure

{
  fruit: [
    'APPLE',
    'APPLE',
    'ORANGE',
    'GRAPEFRUIT',
    'WATERMELON'
  ],
  farmers: {
    1: {
      id: 1,
      name: 'John Smith',
      paid: false
    },
    2: {
      id: 2,
      name: 'Sally Jones',
      paid: false
    }
  }
}

With this more complex state, we can split our reducer into two separate reducers:

Separate Reducers for Each Slice

// Handles only the fruit array
const fruitReducer = (state = [], action) => {
  switch (action.type) {
    case 'ADD_FRUIT':
      return [...state, action.fruit];
    // other fruit-related cases
    default:
      return state;
  }
};

// Handles only the farmers object
const farmersReducer = (state = {}, action) => {
  switch (action.type) {
    case 'HIRE_FARMER':
      return {
        ...state,
        [action.id]: {
          id: action.id,
          name: action.name,
          paid: false
        }
      };
    case 'PAY_FARMER':
      return {
        ...state,
        [action.id]: {
          ...state[action.id],
          paid: true
        }
      };
    default:
      return state;
  }
};

This approach, called reducer composition, is the fundamental pattern of building Redux applications. Each reducer only receives and manages its own slice of state, making the code more maintainable and focused.

Combining Reducers

Once you've split your reducers, you need to recombine them into a single root reducer that the Redux store can use. Redux provides the combineReducers utility for exactly this purpose.

Using combineReducers

import { combineReducers } from 'redux';
import { createStore } from 'redux';

// Combine our reducers
const rootReducer = combineReducers({
  fruit: fruitReducer,
  farmers: farmersReducer
});

// Create the store with the root reducer
const store = createStore(rootReducer);

export default store;

combineReducers creates a function that calls each reducer with its slice of state and combines the results into a single state object. The keys in the object passed to combineReducers determine the shape of your state tree.

Folder Structure Example: In a real-world Redux application, you might organize your reducers like this:

src/
  reducers/
    index.js         // Combines all reducers using combineReducers
    fruitReducer.js  // Manages the fruit slice of state
    farmersReducer.js // Manages the farmers slice of state

Practical Exercise: Building a Task Management Reducer

Let's apply what we've learned by creating reducers for a task management application.

Task Management Reducers

Step 1: Define the initial state structure.

// src/reducers/initialState.js
export const initialState = {
  tasks: [],
  categories: {},
  ui: {
    activeFilter: 'ALL',
    searchTerm: ''
  }
};

Step 2: Create a reducer for the tasks slice.

// src/reducers/tasksReducer.js
import { initialState } from './initialState';

export const tasksReducer = (state = initialState.tasks, action) => {
  switch (action.type) {
    case 'ADD_TASK':
      return [...state, {
        id: action.id,
        text: action.text,
        completed: false,
        categoryId: action.categoryId,
        createdAt: new Date().toISOString()
      }];
      
    case 'TOGGLE_TASK':
      return state.map(task => 
        task.id === action.id 
          ? { ...task, completed: !task.completed } 
          : task
      );
      
    case 'DELETE_TASK':
      return state.filter(task => task.id !== action.id);
      
    case 'UPDATE_TASK_TEXT':
      return state.map(task => 
        task.id === action.id 
          ? { ...task, text: action.text } 
          : task
      );
      
    default:
      return state;
  }
};

Step 3: Create a reducer for the categories slice.

// src/reducers/categoriesReducer.js
import { initialState } from './initialState';

export const categoriesReducer = (state = initialState.categories, action) => {
  switch (action.type) {
    case 'ADD_CATEGORY':
      return {
        ...state,
        [action.id]: {
          id: action.id,
          name: action.name,
          color: action.color || '#cccccc' // Default color
        }
      };
      
    case 'DELETE_CATEGORY':
      const newState = { ...state };
      delete newState[action.id]; // Remove the category
      return newState;
      
    case 'UPDATE_CATEGORY':
      return {
        ...state,
        [action.id]: {
          ...state[action.id],
          ...action.updates
        }
      };
      
    default:
      return state;
  }
};

Step 4: Create a reducer for the UI slice.

// src/reducers/uiReducer.js
import { initialState } from './initialState';

export const uiReducer = (state = initialState.ui, action) => {
  switch (action.type) {
    case 'SET_FILTER':
      return {
        ...state,
        activeFilter: action.filter
      };
      
    case 'SET_SEARCH_TERM':
      return {
        ...state,
        searchTerm: action.term
      };
      
    default:
      return state;
  }
};

Step 5: Combine the reducers.

// src/reducers/index.js
import { combineReducers } from 'redux';
import { tasksReducer } from './tasksReducer';
import { categoriesReducer } from './categoriesReducer';
import { uiReducer } from './uiReducer';

const rootReducer = combineReducers({
  tasks: tasksReducer,
  categories: categoriesReducer,
  ui: uiReducer
});

export default rootReducer;

Step 6: Create the store.

// src/store.js
import { createStore } from 'redux';
import rootReducer from './reducers';

const store = createStore(rootReducer);

export default store;

Advanced Reducer Patterns

Handling Nested State

Working with deeply nested state can be challenging. Here are techniques to update nested objects immutably:

Updating Nested Objects

// Initial state
const state = {
  user: {
    profile: {
      name: 'John',
      address: {
        city: 'New York',
        zip: '10001'
      }
    }
  }
};

// Update nested property immutably
const nextState = {
  ...state,
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      address: {
        ...state.user.profile.address,
        city: 'Boston' // Only this value changes
      }
    }
  }
};

For complex nested updates, consider using a utility library like Immer.js which allows you to write code that appears to mutate state but actually produces immutable updates.

Using Immer.js

import produce from 'immer';

const userReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'UPDATE_CITY':
      return produce(state, draft => {
        // This looks like mutation but isn't!
        draft.user.profile.address.city = action.city;
      });
    
    default:
      return state;
  }
};

Handling Collections of Objects

When working with collections of objects, consider normalizing your state to make updates more efficient:

Normalized State Structure

// Instead of an array:
const badState = {
  tasks: [
    { id: 1, text: 'Buy milk', categoryId: 'grocery' },
    { id: 2, text: 'Walk dog', categoryId: 'pets' }
  ]
};

// Use an object with IDs as keys:
const goodState = {
  tasks: {
    1: { id: 1, text: 'Buy milk', categoryId: 'grocery' },
    2: { id: 2, text: 'Walk dog', categoryId: 'pets' }
  }
};

With normalized state, updating a specific item becomes much easier:

// Update a task in normalized state
const nextState = {
  ...state,
  tasks: {
    ...state.tasks,
    [action.id]: {
      ...state.tasks[action.id],
      text: action.text
    }
  }
};

Common Reducer Pitfalls

Accidentally Mutating State

Even experienced developers can accidentally mutate state. Here are some common mistakes to avoid:

Common Mutation Mistakes

// ❌ Push mutates the original array
const badReducer1 = (state = [], action) => {
  switch (action.type) {
    case 'ADD_ITEM':
      state.push(action.item); // Mutation!
      return state;
    // ...
  }
};

// ❌ Directly assigning properties mutates the object
const badReducer2 = (state = {}, action) => {
  switch (action.type) {
    case 'UPDATE_USER':
      state.name = action.name; // Mutation!
      return state;
    // ...
  }
};

Impure Operations in Reducers

Reducers must be pure functions. Avoid these impure operations:

Impure Reducer Operations

// ❌ Using Date.now() or Math.random() directly
const badReducer = (state = {}, action) => {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, {
          id: Math.random(), // Impure! Different results each time
          timestamp: Date.now(), // Impure! Different results each time
          text: action.text
        }]
      };
    // ...
  }
};

Fix: Move Impure Operations to Action Creators

// Action creator (outside the reducer)
const addItem = (text) => ({
  type: 'ADD_ITEM',
  id: Math.random(), // Generate here
  timestamp: Date.now(), // Generate here
  text
});

// Pure reducer
const goodReducer = (state = {}, action) => {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, {
          id: action.id, // Use value from action
          timestamp: action.timestamp, // Use value from action
          text: action.text
        }]
      };
    // ...
  }
};

Debugging Reducers

When your Redux application isn't behaving as expected, debugging reducers is often key to finding the issue:

Reducer Debugging Tips

// Add console.logs in your reducer
const debuggingReducer = (state = initialState, action) => {
  console.log('Previous state:', state);
  console.log('Action:', action);
  
  switch (action.type) {
    case 'IMPORTANT_ACTION':
      const nextState = {
        ...state,
        someValue: action.value
      };
      console.log('Next state:', nextState);
      return nextState;
    
    default:
      return state;
  }
};

Redux DevTools Extension is an essential tool for debugging Redux applications. It allows you to:

Setting Up Redux DevTools

import { createStore } from 'redux';
import rootReducer from './reducers';

const store = createStore(
  rootReducer,
  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);

export default store;

Real-world Applications

Reducers are used in various ways in production applications:

E-commerce

An e-commerce application might have reducers for:

Content Management System

A CMS might include:

Social Media

A social media app might include:

Summary and Key Points

What is a Reducer?

A reducer is a pure function that determines changes to an application's state based on actions sent to the store. It takes the previous state and an action as arguments, and returns the next state.

Key Reducer Rules

  • Never mutate state - always return new state objects
  • Return the existing state for unrecognized actions
  • Set an initial state as the default parameter value
  • Keep reducers pure - no side effects or non-deterministic code

Reducer Composition

As applications grow, split reducers to handle specific slices of state, then combine them with combineReducers to form a single root reducer.

Topics to Explore Next

  • Middleware for side effects (Redux-Thunk, Redux-Saga)
  • Selectors for efficient data access
  • Normalization patterns for complex state
  • Immutable.js or Immer.js for easier immutability
  • Redux Toolkit for simplified Redux development

Challenge Exercise

Now that you understand reducers, try creating a complete set of reducers for a blog application. Your application should support:

Create the necessary reducers and combine them using combineReducers. Ensure all state updates follow the immutability principles we've discussed.