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
- Pure Functions: Same inputs always produce same outputs
- No Side Effects: Don't modify anything outside the function
- Immutable Updates: Never mutate the existing state
- 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' } }