⚙️ 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) => newStatesignature - 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.
- Pure — the same
(state, action)always yields the same result. - No mutation — never change the existing state; return a new value.
- No side effects — no API calls, timers, logging,
Math.random(), orDate.now(). - Always return state — every path returns something, and the
defaultcase 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.
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.
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, andfilter - 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(), orMath.random()in a reducer - Forget the
defaultcase — 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, andfilter— neverpush/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
- Redux Fundamentals — Reducers
- Redux — Immutable Update Patterns
- Redux — combineReducers API
- Redux Toolkit — Writing Reducers with Immer
🚀 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.