Angular NgRx Reducers
Introduction
Reducers are one of the core building blocks of the NgRx state management architecture. In this guide, we'll explore what reducers are, how they work within the NgRx/Redux pattern, and how to implement them in your Angular applications.
A reducer is a pure function that takes the current state and an action as arguments, and returns a new state. This concept is fundamental to understanding how NgRx manages application state in a predictable way.
Current State + Action = New State
By the end of this guide, you'll understand how to create and use reducers effectively in your Angular applications.
What Are NgRx Reducers?
In NgRx, a reducer is responsible for handling transitions from one state to the next state in your application. When an action is dispatched, all registered reducers receive the action, and each reducer determines if it needs to update its slice of the state based on the action type.
Key characteristics of reducers:
- Pure functions - Given the same input, they always produce the same output
- Immutable updates - They never modify the existing state but return a new state object
- Single responsibility - Each reducer typically manages a specific slice of the application state
Creating Your First Reducer
Let's start by creating a simple reducer for a counter feature:
// counter.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { increment, decrement, reset } from './counter.actions';
export const initialState = 0;
export const counterReducer = createReducer(
initialState,
on(increment, (state) => state + 1),
on(decrement, (state) => state - 1),
on(reset, () => 0)
);
In this example:
- We import
createReducer
andon
functions from NgRx - We define an
initialState
(in this case, a number) - We create a reducer using
createReducer
that responds to three actions: increment, decrement, and reset - For each action, we define how the state should change using pure functions
Understanding Reducer Structure
Before NgRx v8, reducers were typically written using switch statements:
// Traditional reducer with switch statement
export function counterReducerOld(state = initialState, action: Action) {
switch (action.type) {
case '[Counter] Increment':
return state + 1;
case '[Counter] Decrement':
return state - 1;
case '[Counter] Reset':
return 0;
default:
return state;
}
}
The modern createReducer
approach is more concise and type-safe, but both achieve the same goal.
Working with Complex State
Most real-world applications have more complex state than a simple counter. Let's look at a more realistic example with a todo list:
// todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { addTodo, toggleTodo, removeTodo, loadTodos, loadTodosSuccess } from './todo.actions';
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
}
export const initialState: TodoState = {
todos: [],
loading: false,
error: null
};
export const todoReducer = createReducer(
initialState,
on(addTodo, (state, { text }) => ({
...state,
todos: [...state.todos, { id: Date.now(), text, completed: false }]
})),
on(toggleTodo, (state, { id }) => ({
...state,
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
})),
on(removeTodo, (state, { id }) => ({
...state,
todos: state.todos.filter(todo => todo.id !== id)
})),
on(loadTodos, state => ({
...state,
loading: true,
error: null
})),
on(loadTodosSuccess, (state, { todos }) => ({
...state,
todos,
loading: false
}))
);
Notice how we:
- Define a more complex state structure with interfaces
- Use the spread operator (
...
) to create a new state object without mutating the original - Use array methods like
map
andfilter
to create new arrays instead of modifying existing ones
Immutability in Reducers
Maintaining immutability is critical when working with NgRx reducers. Let's look at some common patterns for immutable updates:
Updating Objects
// ❌ Mutation (wrong way)
on(updateUser, (state, { user }) => {
state.user = user; // Mutates state directly!
return state;
}),
// ✅ Immutable update (correct way)
on(updateUser, (state, { user }) => ({
...state,
user: { ...user }
})),
Updating Arrays
// ❌ Mutation (wrong way)
on(addItem, (state, { item }) => {
state.items.push(item); // Mutates state directly!
return state;
}),
// ✅ Immutable update (correct way)
on(addItem, (state, { item }) => ({
...state,
items: [...state.items, item]
})),
Updating Nested Objects
// ✅ Immutable update for nested objects
on(updateUserAddress, (state, { address }) => ({
...state,
user: {
...state.user,
address: {
...state.user.address,
...address
}
}
})),
Registering Reducers with the Store
After creating your reducers, you need to register them with the NgRx store:
// app.module.ts
import { StoreModule } from '@ngrx/store';
import { counterReducer } from './counter.reducer';
import { todoReducer } from './todo.reducer';
@NgModule({
imports: [
StoreModule.forRoot({
count: counterReducer,
todos: todoReducer
})
],
// other module configuration
})
export class AppModule { }
For feature modules, you can use StoreModule.forFeature()
:
// todo.module.ts
import { StoreModule } from '@ngrx/store';
import { todoReducer } from './todo.reducer';
@NgModule({
imports: [
StoreModule.forFeature('todos', todoReducer)
],
// other module configuration
})
export class TodoModule { }
Real-World Example: Shopping Cart
Let's implement a shopping cart feature to demonstrate reducers in a realistic scenario:
// cart.actions.ts
import { createAction, props } from '@ngrx/store';
import { Product } from './product.model';
export const addToCart = createAction(
'[Product] Add to Cart',
props<{ product: Product }>()
);
export const removeFromCart = createAction(
'[Cart] Remove Item',
props<{ productId: number }>()
);
export const updateQuantity = createAction(
'[Cart] Update Quantity',
props<{ productId: number, quantity: number }>()
);
export const clearCart = createAction('[Cart] Clear Cart');
// cart.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as CartActions from './cart.actions';
import { Product } from './product.model';
export interface CartItem {
product: Product;
quantity: number;
}
export interface CartState {
items: CartItem[];
totalPrice: number;
totalItems: number;
}
export const initialState: CartState = {
items: [],
totalPrice: 0,
totalItems: 0
};
// Helper function to calculate totals
const calculateTotals = (items: CartItem[]) => {
const totalItems = items.reduce((total, item) => total + item.quantity, 0);
const totalPrice = items.reduce(
(total, item) => total + item.product.price * item.quantity,
0
);
return { totalItems, totalPrice };
};
export const cartReducer = createReducer(
initialState,
on(CartActions.addToCart, (state, { product }) => {
// Check if product already exists in cart
const existingItemIndex = state.items.findIndex(
item => item.product.id === product.id
);
let updatedItems: CartItem[];
if (existingItemIndex > -1) {
// Update quantity of existing item
updatedItems = state.items.map((item, index) =>
index === existingItemIndex
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
// Add new item
updatedItems = [...state.items, { product, quantity: 1 }];
}
const { totalItems, totalPrice } = calculateTotals(updatedItems);
return {
...state,
items: updatedItems,
totalItems,
totalPrice
};
}),
on(CartActions.removeFromCart, (state, { productId }) => {
const updatedItems = state.items.filter(
item => item.product.id !== productId
);
const { totalItems, totalPrice } = calculateTotals(updatedItems);
return {
...state,
items: updatedItems,
totalItems,
totalPrice
};
}),
on(CartActions.updateQuantity, (state, { productId, quantity }) => {
const updatedItems = state.items.map(item =>
item.product.id === productId
? { ...item, quantity: quantity > 0 ? quantity : 1 }
: item
);
const { totalItems, totalPrice } = calculateTotals(updatedItems);
return {
...state,
items: updatedItems,
totalItems,
totalPrice
};
}),
on(CartActions.clearCart, () => initialState)
);
This more complex example demonstrates:
- Managing multiple properties in state (items, totalPrice, totalItems)
- Helper functions for calculations
- Different strategies for updating state based on action payloads
- Comprehensive state recalculation when changes occur
Best Practices for NgRx Reducers
- Keep reducers pure - Never mutate state or perform side effects (like API calls) in reducers
- Normalize complex state - For related entities, consider a normalized structure
- Prefer small, focused reducers - Use feature reducers and combineReducers for organization
- Minimize duplication - Extract common logic to helper functions
- Test your reducers - Since they're pure functions, they're easy to test
Testing Reducers
Since reducers are pure functions, they're straightforward to test:
// cart.reducer.spec.ts
import { cartReducer, initialState } from './cart.reducer';
import * as CartActions from './cart.actions';
describe('Cart Reducer', () => {
const mockProduct = {
id: 1,
name: 'Test Product',
price: 10.99
};
it('should return the default state when initialized with undefined', () => {
const action = { type: 'NOOP' } as any;
const state = cartReducer(undefined, action);
expect(state).toBe(initialState);
});
it('should add an item to an empty cart', () => {
const action = CartActions.addToCart({ product: mockProduct });
const state = cartReducer(initialState, action);
expect(state.items.length).toBe(1);
expect(state.items[0].product).toEqual(mockProduct);
expect(state.items[0].quantity).toBe(1);
expect(state.totalItems).toBe(1);
expect(state.totalPrice).toBe(10.99);
});
it('should increase quantity when adding an existing item', () => {
// First add an item
const addAction = CartActions.addToCart({ product: mockProduct });
let state = cartReducer(initialState, addAction);
// Add the same item again
state = cartReducer(state, addAction);
expect(state.items.length).toBe(1);
expect(state.items[0].quantity).toBe(2);
expect(state.totalItems).toBe(2);
expect(state.totalPrice).toBe(21.98);
});
// More tests...
});
Summary
NgRx reducers are a powerful way to manage state transitions in your Angular applications. By following the principles of pure functions and immutability, reducers make your state changes predictable and easier to debug.
Key takeaways:
- Reducers are pure functions that take current state and an action to produce a new state
- They should never mutate state or produce side effects
- The
createReducer
function provides a clean, type-safe way to define reducers - Immutability patterns are essential when working with complex objects and arrays
- Properly structured reducers make testing and debugging easier
Additional Resources
- Official NgRx Reducer Documentation
- Redux Style Guide (many principles apply to NgRx as well)
- Immutable Update Patterns
Exercises
- Create a reducer for a user profile feature that handles actions for updating different user properties (name, email, settings)
- Implement a notification system reducer that manages a queue of notification messages
- Extend the shopping cart example to include product categories and filter functionality
- Create a reducer that handles pagination state for a list of items
- Practice immutable updates by writing a reducer for a nested form state
By mastering NgRx reducers, you'll build a strong foundation for implementing robust state management in your Angular applications.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)