Skip to main content

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:

  1. Pure functions - Given the same input, they always produce the same output
  2. Immutable updates - They never modify the existing state but return a new state object
  3. 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:

typescript
// 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 and on 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:

typescript
// 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:

typescript
// 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:

  1. Define a more complex state structure with interfaces
  2. Use the spread operator (...) to create a new state object without mutating the original
  3. Use array methods like map and filter 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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
// ✅ 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:

typescript
// 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():

typescript
// 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:

typescript
// 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');
typescript
// 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:

  1. Managing multiple properties in state (items, totalPrice, totalItems)
  2. Helper functions for calculations
  3. Different strategies for updating state based on action payloads
  4. Comprehensive state recalculation when changes occur

Best Practices for NgRx Reducers

  1. Keep reducers pure - Never mutate state or perform side effects (like API calls) in reducers
  2. Normalize complex state - For related entities, consider a normalized structure
  3. Prefer small, focused reducers - Use feature reducers and combineReducers for organization
  4. Minimize duplication - Extract common logic to helper functions
  5. 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:

typescript
// 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

Exercises

  1. Create a reducer for a user profile feature that handles actions for updating different user properties (name, email, settings)
  2. Implement a notification system reducer that manages a queue of notification messages
  3. Extend the shopping cart example to include product categories and filter functionality
  4. Create a reducer that handles pagination state for a list of items
  5. 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! :)