Skip to main content

⚙️ Reducers

Actions describe what happened; reducers decide what the state becomes. A reducer is a pure function of the shape (state, action) => newState — the heart of Redux, where all your state-transition logic lives. Master reducers and immutable updates, and the rest of Redux clicks into place.

Week 6 · Day 1 (Monday: Redux Fundamentals) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Define a reducer and explain the (state, action) => newState signature
  • List the rules a reducer must obey: pure, no mutation, no side effects, always return state
  • Perform immutable updates on numbers, objects, arrays, and nested structures
  • Split logic across multiple reducers and combine them with combineReducers
  • Write straightforward unit tests for a reducer
  • Explain how Redux Toolkit's Immer lets you write "mutating" logic safely

Estimated Time: 75 minutes

Practice: Build a todos reducer with immutable updates, then a combined root reducer.

In This Lesson

What Is a Reducer?

A reducer is a function that takes the current state and an action, and returns the next state. The name comes from the array method reduce: just as reduce folds a list of values into one accumulated result, a Redux reducer folds a stream of actions into one accumulated state, one action at a time.

// The reducer signature — memorize this shape:
(state, action) => newState

// The smallest real reducer:
function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'counter/incremented': return state + 1;
    case 'counter/decremented': return state - 1;
    default:                    return state;
  }
}

🏦 The Bank Teller Analogy

Picture a reducer as a bank teller working under strict rules:

  • Current balance — the current state
  • Transaction slip — the action
  • New balance — the returned next state
  • The teller — the reducer function itself

The teller may not scribble on your old passbook (no mutation), must handle the same slip the same way every time (pure), and only ever computes a new balance from what's on the slip (deterministic). A reducer lives by the same discipline.

The state = 0 default parameter is important: when Redux first creates the store, it calls each reducer with undefined state so the reducer can supply its initial value. That's why every reducer needs a sensible default.

The Four Rules

Redux's predictability rests entirely on reducers following four rules. Break them and you'll get missed re-renders, un-reproducible bugs, and broken time-travel.

graph TD A[A reducer must...] --> B[Be pure] A --> C[Never mutate state] A --> D[Have no side effects] A --> E[Always return state] B --> F[Same input, same output] C --> G[Build new objects & arrays] D --> H[No fetch, timers, or random] E --> I[Default case returns state]
  1. Pure — the same (state, action) always yields the same result.
  2. No mutation — never change the existing state; return a new value.
  3. No side effects — no API calls, timers, logging, Math.random(), or Date.now().
  4. Always return state — every path returns something, and the default case returns the unchanged state.
// ✅ Follows all four rules:
function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'counter/incremented': return state + 1;
    case 'counter/decremented': return state - 1;
    default:                    return state;   // unknown action → unchanged
  }
}

// ❌ Breaks the rules in three different ways:
function badReducer(state = 0, action) {
  switch (action.type) {
    case 'counter/incremented':
      state++;                 // ❌ mutation
      return state;
    case 'counter/randomized':
      return Math.random();    // ❌ not pure / non-deterministic
    case 'data/fetched':
      fetch('/api/data');      // ❌ side effect
      return state;
    // ❌ no default case — returns undefined for unknown actions
  }
}

⚠️ Why "no mutation" is non-negotiable

Redux detects change by comparing references, not by deep-comparing contents. If you mutate the existing object, its reference stays identical, so Redux concludes "nothing changed" and the UI never updates. Returning a brand-new object gives a new reference — the signal that a re-render is needed.

Immutable Updates

"Immutable update" means producing a new value that reflects the change, while leaving the original untouched. The spread operator (...) and non-mutating array methods (map, filter, concat) are your everyday tools.

Immutable update: the old state stays unchanged while a new object is created with the changed field old state { count: 1 } untouched { ...state, count: 2 } new state { count: 2 } new reference
A reducer copies the old state, overrides what changed, and returns the fresh object — the original is never modified.

Objects

function userReducer(state = {}, action) {
  switch (action.type) {
    case 'user/nameUpdated':
      return { ...state, name: action.payload };   // copy, then override
    case 'user/profileUpdated':
      return {
        ...state,
        profile: { ...state.profile, ...action.payload } // copy nested too
      };
    default:
      return state;
  }
}

Arrays

function todosReducer(state = [], action) {
  switch (action.type) {
    case 'todos/added':
      return [...state, action.payload];             // add without push()

    case 'todos/removed':
      return state.filter(todo => todo.id !== action.payload);

    case 'todos/toggled':
      return state.map(todo =>
        todo.id === action.payload
          ? { ...todo, completed: !todo.completed }  // new object for the match
          : todo                                     // others pass through
      );

    default:
      return state;
  }
}

💡 Mutating vs non-mutating array methods

Avoid push, pop, splice, sort, and reverse in reducers — they mutate in place. Reach instead for map, filter, concat, slice, and the spread operator, which all return new arrays.

Nested structures

Deep nesting is where immutable updates get verbose — every level from the change up to the root must be copied. This "spread pyramid" is a real pain point, and one of the strongest arguments for Redux Toolkit (which we cover below).

// Updating one post inside one user requires copying every level up:
case 'users/postUpdated': {
  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
        )
      }
    }
  };
}

Combining Reducers

One giant reducer handling every action becomes unmanageable fast. Instead, write a small reducer per slice of state — user, posts, ui — and let combineReducers stitch them into a single root reducer. Each slice reducer owns and only sees its own piece of state.

graph TD A[Root reducer] --> B[userReducer] A --> C[postsReducer] A --> D[uiReducer] B --> E[state.user] C --> F[state.posts] D --> G[state.ui]
import { combineReducers } from 'redux';

function userReducer(state = null, action) {
  switch (action.type) {
    case 'user/loggedIn':  return action.payload;
    case 'user/loggedOut': return null;
    default:               return state;
  }
}

function postsReducer(state = [], action) {
  switch (action.type) {
    case 'posts/added':   return [...state, action.payload];
    case 'posts/removed': return state.filter(p => p.id !== action.payload);
    default:              return state;
  }
}

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

// Combine them — the keys become the top-level state shape:
const rootReducer = combineReducers({
  user:  userReducer,
  posts: postsReducer,
  ui:    uiReducer
});

// Resulting state:
// { user: null, posts: [], ui: { loading: false, error: null } }

Under the hood, combineReducers simply calls each slice reducer with its own slice of state and assembles the results:

// A hand-written equivalent of what combineReducers does:
function rootReducer(state = {}, action) {
  return {
    user:  userReducer(state.user, action),
    posts: postsReducer(state.posts, action),
    ui:    uiReducer(state.ui, action)
  };
}

Testing Reducers

Reducers are pure functions, which makes them a joy to test: give a starting state and an action, assert on the returned state. No mocks, no DOM, no async.

import todosReducer from './todosReducer';

describe('todosReducer', () => {
  it('returns the initial state', () => {
    expect(todosReducer(undefined, { type: '@@INIT' })).toEqual([]);
  });

  it('handles todos/added immutably', () => {
    const before = [];
    const action = { type: 'todos/added', payload: { id: 1, text: 'Hi', completed: false } };
    const after = todosReducer(before, action);

    expect(after).toEqual([{ id: 1, text: 'Hi', completed: false }]);
    expect(after).not.toBe(before);   // a NEW array was returned
  });

  it('toggles the matching todo only', () => {
    const before = [{ id: 1, text: 'Hi', completed: false }];
    const after = todosReducer(before, { type: 'todos/toggled', payload: 1 });
    expect(after[0].completed).toBe(true);
  });
});

✅ Assert on immutability

The line expect(after).not.toBe(before) is the one people forget. It proves your reducer returned a new reference rather than mutating the input — catching the exact bug that makes the UI silently fail to update.

Reducers the RTK Way

Those spread pyramids for nested updates are error-prone and tedious. Redux Toolkit solves this by wiring in Immer: inside a createSlice reducer, you write code that looks like mutation, and Immer produces a correct immutable update behind the scenes.

import { createSlice } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    // These LOOK like mutations — Immer makes them safely immutable:
    added(state, action) {
      state.push(action.payload);          // no spread needed!
    },
    toggled(state, action) {
      const todo = state.find(t => t.id === action.payload);
      if (todo) todo.completed = !todo.completed;
    },
    removed(state, action) {
      return state.filter(t => t.id !== action.payload);
    }
  }
});

export const { added, toggled, removed } = todosSlice.actions;
export default todosSlice.reducer;

And combining slices is handled by configureStore, which calls combineReducers for you:

import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './todosSlice';
import userReducer  from './userSlice';

const store = configureStore({
  reducer: {
    todos: todosReducer,
    user:  userReducer
  }
});
// state shape: { todos: [...], user: {...} }

⚠️ The Immer rule: mutate or return, never both

Inside a createSlice reducer you may either mutate the state draft or return a brand-new value — but not both in the same case. And remember: this "mutation" superpower only works inside RTK/Immer. In plain reducers, mutation is still a bug.

Practice & Quiz

🏋️ Exercise 1: An immutable todos reducer

Goal: Write a plain reducer for a todos array handling todos/added (append the payload), todos/removed (remove by id), and todos/toggled (flip completed on the matching id) — all immutably.

function todosReducer(state = [], action) {
  switch (action.type) {
    case 'todos/added':
      // TODO: append action.payload without mutating
    case 'todos/removed':
      // TODO: remove the todo whose id === action.payload
    case 'todos/toggled':
      // TODO: flip completed on the matching todo
    default:
      return state;
  }
}
💡 Hint

Use [...state, action.payload] to add, state.filter(...) to remove, and state.map(...) to toggle — returning { ...todo, completed: !todo.completed } for the matching id and todo for the rest.

✅ Solution
function todosReducer(state = [], action) {
  switch (action.type) {
    case 'todos/added':
      return [...state, action.payload];
    case 'todos/removed':
      return state.filter(todo => todo.id !== action.payload);
    case 'todos/toggled':
      return state.map(todo =>
        todo.id === action.payload
          ? { ...todo, completed: !todo.completed }
          : todo
      );
    default:
      return state;
  }
}

🏋️ Exercise 2: Combine two reducers

Goal: Given a todosReducer and a filterReducer (state is a string like 'all', handling filter/set), build a root reducer whose state is { todos, filter }.

✅ Solution
import { combineReducers } from 'redux';

function filterReducer(state = 'all', action) {
  switch (action.type) {
    case 'filter/set': return action.payload;
    default:           return state;
  }
}

const rootReducer = combineReducers({
  todos:  todosReducer,
  filter: filterReducer
});
// state: { todos: [...], filter: 'all' }

🎯 Quick Quiz

Question 1: What should a reducer return when it receives an action type it doesn't recognize?

Question 2: Which line correctly adds an item to an array immutably in a plain reducer?

Question 3: How does Redux Toolkit let you write state.push(...) inside a reducer safely?

Best Practices & Pitfalls

✅ Do

  • Keep reducers pure: no side effects, no non-deterministic values
  • Always give a default state and a default case that returns state
  • Update immutably with spread, map, and filter
  • Split logic into slice reducers and combine them
  • Prefer Redux Toolkit's createSlice — Immer handles immutability for you

❌ Don't

  • Mutate state with push, splice, or direct assignment (in plain reducers)
  • Call fetch, localStorage, timers, Date.now(), or Math.random() in a reducer
  • Forget the default case — unknown actions must return state unchanged
  • Both mutate the draft and return a value in the same RTK reducer case

⚠️ Keep async out of reducers

Reducers are synchronous and pure — full stop. Data fetching, timers, and other side effects belong in middleware: redux-thunk or RTK's createAsyncThunk. The reducer's only job is to compute the next state from the action it's handed.

Summary

🎉 Key Takeaways

  • A reducer is a pure function: (state, action) => newState
  • The four rules: pure, no mutation, no side effects, always return state
  • Update immutably with spread, map, and filter — never push/splice
  • Split state into slice reducers and join them with combineReducers
  • Redux Toolkit + Immer lets you write draft "mutations" that become safe immutable updates

📚 Additional Resources

🚀 What's Next?

You now have the full trio — store, actions, reducers — working together. Next you'll plug Redux into the UI: Connecting Redux to React, using the React-Redux Provider, and the useSelector and useDispatch hooks.

🎉 You've got the core!

Reducers are where Redux logic lives. With pure functions and immutable updates under your belt, everything from here is about wiring it to your components.