Published on June 10, 2023
NgRx is a powerful state management library for Angular applications, inspired by Redux. It helps you manage application state in a predictable way by enforcing a unidirectional data flow and immutable state updates. This guide will walk you through the process of integrating NgRx into an Angular application, from installation to implementation of core concepts.
Before diving into implementation, it’s important to understand why state management is necessary and how NgRx helps:
Before starting, ensure you have:
npm install -g @angular/cli
)If you don’t have an existing project, create a new Angular application:
ng new my-ngrx-app
cd my-ngrx-app
Install the required NgRx packages:
ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/entity
ng add @ngrx/store-devtools
ng add @ngrx/router-store
Alternatively, you can install them all at once:
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools @ngrx/router-store --save
Before implementation, let’s understand the core NgRx concepts:
Let’s implement a simple todo list application with NgRx:
First, define what your application state will look like:
// src/app/store/state/todo.state.ts
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
}
export const initialTodoState: TodoState = {
todos: [],
loading: false,
error: null
};
Define actions that can modify the state:
// src/app/store/actions/todo.actions.ts
import { createAction, props } from '@ngrx/store';
import { Todo } from '../state/todo.state';
export const loadTodos = createAction('[Todo] Load Todos');
export const loadTodosSuccess = createAction(
'[Todo] Load Todos Success',
props<{ todos: Todo[] }>()
);
export const loadTodosFailure = createAction(
'[Todo] Load Todos Failure',
props<{ error: string }>()
);
export const addTodo = createAction(
'[Todo] Add Todo',
props<{ text: string }>()
);
export const addTodoSuccess = createAction(
'[Todo] Add Todo Success',
props<{ todo: Todo }>()
);
export const addTodoFailure = createAction(
'[Todo] Add Todo Failure',
props<{ error: string }>()
);
export const toggleTodo = createAction(
'[Todo] Toggle Todo',
props<{ id: number }>()
);
Create reducers to handle state changes based on actions:
// src/app/store/reducers/todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { initialTodoState } from '../state/todo.state';
import * as TodoActions from '../actions/todo.actions';
export const todoReducer = createReducer(
initialTodoState,
on(TodoActions.loadTodos, state => ({
...state,
loading: true,
error: null
})),
on(TodoActions.loadTodosSuccess, (state, { todos }) => ({
...state,
todos,
loading: false
})),
on(TodoActions.loadTodosFailure, (state, { error }) => ({
...state,
error,
loading: false
})),
on(TodoActions.addTodoSuccess, (state, { todo }) => ({
...state,
todos: [...state.todos, todo]
})),
on(TodoActions.toggleTodo, (state, { id }) => ({
...state,
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
}))
);
Effects handle side effects like API calls:
// src/app/store/effects/todo.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';
import { TodoService } from '../../services/todo.service';
import * as TodoActions from '../actions/todo.actions';
@Injectable()
export class TodoEffects {
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.loadTodos),
mergeMap(() =>
this.todoService.getTodos().pipe(
map(todos => TodoActions.loadTodosSuccess({ todos })),
catchError(error =>
of(TodoActions.loadTodosFailure({ error: error.message }))
)
)
)
)
);
addTodo$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.addTodo),
mergeMap(({ text }) =>
this.todoService.addTodo(text).pipe(
map(todo => TodoActions.addTodoSuccess({ todo })),
catchError(error =>
of(TodoActions.addTodoFailure({ error: error.message }))
)
)
)
)
);
constructor(
private actions$: Actions,
private todoService: TodoService
) {}
}
For this to work, you’ll need a TodoService:
// src/app/services/todo.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Todo } from '../store/state/todo.state';
@Injectable({
providedIn: 'root'
})
export class TodoService {
private apiUrl = 'api/todos'; // Replace with your API URL
constructor(private http: HttpClient) {}
getTodos(): Observable<Todo[]> {
return this.http.get<Todo[]>(this.apiUrl);
}
addTodo(text: string): Observable<Todo> {
const todo: Partial<Todo> = {
text,
completed: false
};
return this.http.post<Todo>(this.apiUrl, todo);
}
toggleTodo(id: number): Observable<Todo> {
return this.http.patch<Todo>(`${this.apiUrl}/${id}`, {});
}
}
Create selectors to extract specific pieces of state:
// src/app/store/selectors/todo.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TodoState } from '../state/todo.state';
export const selectTodoState = createFeatureSelector<TodoState>('todos');
export const selectAllTodos = createSelector(
selectTodoState,
state => state.todos
);
export const selectTodosLoading = createSelector(
selectTodoState,
state => state.loading
);
export const selectTodosError = createSelector(
selectTodoState,
state => state.error
);
export const selectCompletedTodos = createSelector(
selectAllTodos,
todos => todos.filter(todo => todo.completed)
);
export const selectIncompleteTodos = createSelector(
selectAllTodos,
todos => todos.filter(todo => !todo.completed)
);
Register the reducers and effects in your app module:
// src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '../environments/environment';
import { AppComponent } from './app.component';
import { todoReducer } from './store/reducers/todo.reducer';
import { TodoEffects } from './store/effects/todo.effects';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
HttpClientModule,
StoreModule.forRoot({ todos: todoReducer }),
EffectsModule.forRoot([TodoEffects]),
StoreDevtoolsModule.instrument({
maxAge: 25, // Retains last 25 states
logOnly: environment.production
})
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
Now you can use the store in your components:
// src/app/todo-list/todo-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Todo } from '../store/state/todo.state';
import * as TodoActions from '../store/actions/todo.actions';
import * as TodoSelectors from '../store/selectors/todo.selectors';
@Component({
selector: 'app-todo-list',
template: `
<div *ngIf="loading$ | async">Loading...</div>
<div *ngIf="error$ | async as error" class="error"></div>
<form (ngSubmit)="addTodo()">
<input [(ngModel)]="newTodoText" name="newTodo" placeholder="Add a new todo">
<button type="submit">Add</button>
</form>
<ul>
<li *ngFor="let todo of todos$ | async"
[class.completed]="todo.completed"
(click)="toggleTodo(todo.id)">
</li>
</ul>
<div>
<p>Completed: </p>
<p>Incomplete: </p>
</div>
`,
styles: [`
.completed {
text-decoration: line-through;
color: gray;
}
.error {
color: red;
}
`]
})
export class TodoListComponent implements OnInit {
todos$: Observable<Todo[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
completedTodos$: Observable<Todo[]>;
incompleteTodos$: Observable<Todo[]>;
newTodoText = '';
constructor(private store: Store) {
this.todos$ = this.store.select(TodoSelectors.selectAllTodos);
this.loading$ = this.store.select(TodoSelectors.selectTodosLoading);
this.error$ = this.store.select(TodoSelectors.selectTodosError);
this.completedTodos$ = this.store.select(TodoSelectors.selectCompletedTodos);
this.incompleteTodos$ = this.store.select(TodoSelectors.selectIncompleteTodos);
}
ngOnInit(): void {
this.store.dispatch(TodoActions.loadTodos());
}
addTodo(): void {
if (this.newTodoText.trim()) {
this.store.dispatch(TodoActions.addTodo({ text: this.newTodoText }));
this.newTodoText = '';
}
}
toggleTodo(id: number): void {
this.store.dispatch(TodoActions.toggleTodo({ id }));
}
}
Don’t forget to add the component to your app module:
// src/app/app.module.ts
import { FormsModule } from '@angular/forms';
import { TodoListComponent } from './todo-list/todo-list.component';
@NgModule({
declarations: [
AppComponent,
TodoListComponent
],
imports: [
// ... other imports
FormsModule
],
// ...
})
export class AppModule {}
NgRx Entity helps manage collections of entities with less boilerplate:
// src/app/store/state/todo.state.ts
import { EntityState, createEntityAdapter } from '@ngrx/entity';
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export const todoAdapter = createEntityAdapter<Todo>();
export interface TodoState extends EntityState<Todo> {
loading: boolean;
error: string | null;
}
export const initialTodoState: TodoState = todoAdapter.getInitialState({
loading: false,
error: null
});
// src/app/store/reducers/todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { todoAdapter, initialTodoState } from '../state/todo.state';
import * as TodoActions from '../actions/todo.actions';
export const todoReducer = createReducer(
initialTodoState,
on(TodoActions.loadTodos, state => ({
...state,
loading: true,
error: null
})),
on(TodoActions.loadTodosSuccess, (state, { todos }) =>
todoAdapter.setAll(todos, { ...state, loading: false })
),
on(TodoActions.loadTodosFailure, (state, { error }) => ({
...state,
error,
loading: false
})),
on(TodoActions.addTodoSuccess, (state, { todo }) =>
todoAdapter.addOne(todo, state)
),
on(TodoActions.toggleTodo, (state, { id }) => {
const todo = state.entities[id];
if (!todo) return state;
return todoAdapter.updateOne(
{ id, changes: { completed: !todo.completed } },
state
);
})
);
// src/app/store/selectors/todo.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { todoAdapter, TodoState } from '../state/todo.state';
export const selectTodoState = createFeatureSelector<TodoState>('todos');
export const {
selectAll: selectAllTodos,
selectEntities: selectTodoEntities,
selectIds: selectTodoIds,
selectTotal: selectTotalTodos
} = todoAdapter.getSelectors(selectTodoState);
export const selectTodosLoading = createSelector(
selectTodoState,
state => state.loading
);
export const selectTodosError = createSelector(
selectTodoState,
state => state.error
);
export const selectCompletedTodos = createSelector(
selectAllTodos,
todos => todos.filter(todo => todo.completed)
);
export const selectIncompleteTodos = createSelector(
selectAllTodos,
todos => todos.filter(todo => !todo.completed)
);
NgRx Router Store connects the Angular Router to the NgRx store:
// src/app/app.module.ts
import { StoreRouterConnectingModule, routerReducer } from '@ngrx/router-store';
@NgModule({
imports: [
// ... other imports
StoreModule.forRoot({
todos: todoReducer,
router: routerReducer
}),
StoreRouterConnectingModule.forRoot()
],
// ...
})
export class AppModule {}
You can then create custom router serializers and selectors to access route information from the store.
[Source] Event
.createAction
function to create type-safe actions.// src/app/store/reducers/todo.reducer.spec.ts
import { todoReducer } from './todo.reducer';
import { initialTodoState } from '../state/todo.state';
import * as TodoActions from '../actions/todo.actions';
describe('Todo Reducer', () => {
it('should return the default state', () => {
const action = { type: 'NOOP' } as any;
const state = todoReducer(undefined, action);
expect(state).toBe(initialTodoState);
});
it('should toggle a todo', () => {
const todo = { id: 1, text: 'Test', completed: false };
const initialState = {
...initialTodoState,
todos: [todo]
};
const action = TodoActions.toggleTodo({ id: 1 });
const state = todoReducer(initialState, action);
expect(state.todos[0].completed).toBe(true);
});
});
// src/app/store/selectors/todo.selectors.spec.ts
import * as fromSelectors from './todo.selectors';
import { TodoState } from '../state/todo.state';
describe('Todo Selectors', () => {
const initialState: TodoState = {
todos: [
{ id: 1, text: 'Test 1', completed: false },
{ id: 2, text: 'Test 2', completed: true }
],
loading: false,
error: null
};
it('should select all todos', () => {
const result = fromSelectors.selectAllTodos.projector(initialState);
expect(result.length).toBe(2);
});
it('should select completed todos', () => {
const todos = initialState.todos;
const result = fromSelectors.selectCompletedTodos.projector(todos);
expect(result.length).toBe(1);
expect(result[0].id).toBe(2);
});
});
// src/app/store/effects/todo.effects.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of, throwError } from 'rxjs';
import { TodoEffects } from './todo.effects';
import { TodoService } from '../../services/todo.service';
import * as TodoActions from '../actions/todo.actions';
describe('TodoEffects', () => {
let actions$: Observable<any>;
let effects: TodoEffects;
let todoService: jasmine.SpyObj<TodoService>;
beforeEach(() => {
const spy = jasmine.createSpyObj('TodoService', ['getTodos', 'addTodo']);
TestBed.configureTestingModule({
providers: [
TodoEffects,
provideMockActions(() => actions$),
{ provide: TodoService, useValue: spy }
]
});
effects = TestBed.inject(TodoEffects);
todoService = TestBed.inject(TodoService) as jasmine.SpyObj<TodoService>;
});
it('should load todos successfully', () => {
const todos = [{ id: 1, text: 'Test', completed: false }];
actions$ = of(TodoActions.loadTodos());
todoService.getTodos.and.returnValue(of(todos));
effects.loadTodos$.subscribe(action => {
expect(action).toEqual(TodoActions.loadTodosSuccess({ todos }));
});
});
it('should handle errors when loading todos', () => {
const error = 'Error loading todos';
actions$ = of(TodoActions.loadTodos());
todoService.getTodos.and.returnValue(throwError(() => new Error(error)));
effects.loadTodos$.subscribe(action => {
expect(action).toEqual(TodoActions.loadTodosFailure({ error }));
});
});
});
Integrating NgRx with Angular provides a robust solution for state management in complex applications. By following the steps outlined in this guide, you can implement a predictable, maintainable state management system that scales with your application.
Remember that NgRx introduces complexity, so evaluate whether your application needs it. For simpler applications, consider alternatives like RxJS with services or the Angular Component Store.
As you become more familiar with NgRx, explore advanced features like Entity, Router Store, and Meta-Reducers to further enhance your state management capabilities.