Angular NgRx Introduction
Understanding State Management in Angular
When developing complex applications in Angular, managing state across multiple components becomes challenging. As your application grows, keeping track of what changes where and when can lead to bugs that are difficult to trace and fix.
NgRx provides a robust solution for state management in Angular applications by implementing the Redux pattern. It helps you manage state in a predictable way, making your applications easier to debug and test.
What is NgRx?
NgRx is a framework for building reactive applications in Angular. It provides state management, isolation of side effects, entity collection management, router bindings, code generation, and developer tools that enhance your development experience.
NgRx is based on the Redux pattern, which follows these core principles:
- Single source of truth: The state of your entire application is stored in a single store
- State is read-only: You can't directly modify the state; you dispatch actions to express intent
- Changes are made with pure functions: Reducers are pure functions that take the previous state and an action to return a new state
Core Concepts of NgRx
Before diving into code, let's understand the main building blocks of NgRx:
1. Store
The store is the central repository of your application state. It's an immutable data structure that holds the state and provides methods to interact with it.
2. Actions
Actions describe unique events that happen throughout your application. They are the only way to change the state in NgRx. Actions have a type and an optional payload.
3. Reducers
Reducers are pure functions that take the current state and an action to determine what the new state should be. They handle state transitions based on the actions dispatched.
4. Selectors
Selectors are pure functions that extract specific pieces of information from the store. They help you compute derived data from the store state.
5. Effects
Effects isolate side effects from your components, making them more maintainable and testable. They listen for dispatched actions and perform tasks like API calls.
Getting Started with NgRx
Installation
To get started with NgRx, you need to install the following packages:
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools --save
Setting Up the Store
Let's create a simple counter example to demonstrate how NgRx works in an Angular application.
1. Define the State Interface
First, define what your application state looks like:
// counter.state.ts
export interface CounterState {
count: number;
}
export const initialState: CounterState = {
count: 0
};
2. Create Actions
Next, define the actions that can be performed on the state:
// counter.actions.ts
import { createAction, props } from '@ngrx/store';
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const reset = createAction('[Counter] Reset');
export const incrementByAmount = createAction(
'[Counter] Increment By Amount',
props<{ amount: number }>()
);
3. Create a Reducer
Now create a reducer to handle these actions:
// counter.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as CounterActions from './counter.actions';
import { initialState } from './counter.state';
export const counterReducer = createReducer(
initialState,
on(CounterActions.increment, state => ({
...state,
count: state.count + 1
})),
on(CounterActions.decrement, state => ({
...state,
count: state.count - 1
})),
on(CounterActions.reset, state => ({
...state,
count: 0
})),
on(CounterActions.incrementByAmount, (state, { amount }) => ({
...state,
count: state.count + amount
}))
);
4. Register the Store in Your App Module
Configure the NgRx store in your app module:
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { counterReducer } from './counter.reducer';
import { AppComponent } from './app.component';
import { environment } from '../environments/environment';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
StoreModule.forRoot({ count: counterReducer }),
StoreDevtoolsModule.instrument({
maxAge: 25,
logOnly: environment.production
})
],
bootstrap: [AppComponent]
})
export class AppModule {}
5. Create Selectors
Create selectors to extract specific pieces of state:
// counter.selectors.ts
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { CounterState } from './counter.state';
export const selectCounterState = createFeatureSelector<CounterState>('count');
export const selectCount = createSelector(
selectCounterState,
state => state.count
);
Using NgRx in Components
Now let's see how to use the store in your Angular components:
// counter.component.ts
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { increment, decrement, reset, incrementByAmount } from './counter.actions';
import { selectCount } from './counter.selectors';
@Component({
selector: 'app-counter',
template: `
<h2>Counter: {{ count$ | async }}</h2>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
<div>
<input type="number" #amountInput>
<button (click)="incrementByAmount(amountInput.value)">Add Amount</button>
</div>
`
})
export class CounterComponent {
count$: Observable<number>;
constructor(private store: Store) {
this.count$ = this.store.select(selectCount);
}
increment() {
this.store.dispatch(increment());
}
decrement() {
this.store.dispatch(decrement());
}
reset() {
this.store.dispatch(reset());
}
incrementByAmount(value: string) {
this.store.dispatch(incrementByAmount({ amount: Number(value) || 0 }));
}
}
Working with Side Effects
In real applications, you'll often need to perform asynchronous operations like API calls. This is where NgRx Effects come in.
Let's add an effect to our counter example:
// counter.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import * as CounterActions from './counter.actions';
@Injectable()
export class CounterEffects {
// Define a new action for loading a count from an API
loadCount$ = createEffect(() => this.actions$.pipe(
ofType('[Counter] Load Count'),
mergeMap(() => this.counterService.getCount()
.pipe(
map(count => ({ type: '[Counter] Load Count Success', payload: count })),
catchError(() => EMPTY)
))
)
);
constructor(
private actions$: Actions,
private counterService: CounterService
) {}
}
Don't forget to register the effects in your module:
// app.module.ts
import { EffectsModule } from '@ngrx/effects';
import { CounterEffects } from './counter.effects';
@NgModule({
imports: [
// other imports...
EffectsModule.forRoot([CounterEffects])
]
})
export class AppModule {}
A Real-World Example: Todo Application
Let's explore a more practical example with a Todo application:
Step 1: Define the Todo 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 initialState: TodoState = {
todos: [],
loading: false,
error: null
};
Step 2: Create Todo Actions
// todo.actions.ts
import { createAction, props } from '@ngrx/store';
import { Todo } from './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 toggleTodo = createAction(
'[Todo] Toggle Todo',
props<{ id: number }>()
);
export const deleteTodo = createAction(
'[Todo] Delete Todo',
props<{ id: number }>()
);
Step 3: Create Todo Reducer
// todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as TodoActions from './todo.actions';
import { initialState } from './todo.state';
export const todoReducer = createReducer(
initialState,
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
)
})),
on(TodoActions.deleteTodo, (state, { id }) => ({
...state,
todos: state.todos.filter(todo => todo.id !== id)
}))
);
Step 4: Create Todo Effects
// todo.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError, concatMap } from 'rxjs/operators';
import * as TodoActions from './todo.actions';
import { TodoService } from './todo.service';
@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),
concatMap(({ text }) => this.todoService.addTodo(text)
.pipe(
map(todo => TodoActions.addTodoSuccess({ todo })),
catchError(error => of(TodoActions.loadTodosFailure({ error: error.message })))
))
)
);
constructor(
private actions$: Actions,
private todoService: TodoService
) {}
}
Step 5: Create Todo Selectors
// todo.selectors.ts
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { TodoState } from './todo.state';
export const selectTodoState = createFeatureSelector<TodoState>('todos');
export const selectAllTodos = createSelector(
selectTodoState,
state => state.todos
);
export const selectCompletedTodos = createSelector(
selectAllTodos,
todos => todos.filter(todo => todo.completed)
);
export const selectActiveTodos = createSelector(
selectAllTodos,
todos => todos.filter(todo => !todo.completed)
);
export const selectLoading = createSelector(
selectTodoState,
state => state.loading
);
export const selectError = createSelector(
selectTodoState,
state => state.error
);
Step 6: Implement the Todo Component
// todo-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Todo } from './todo.state';
import * as TodoActions from './todo.actions';
import * as TodoSelectors from './todo.selectors';
@Component({
selector: 'app-todo-list',
template: `
<div *ngIf="loading$ | async">Loading...</div>
<div *ngIf="error$ | async as error" class="error">{{ error }}</div>
<div>
<input #todoInput placeholder="What needs to be done?">
<button (click)="addTodo(todoInput.value); todoInput.value = ''">Add Todo</button>
</div>
<ul>
<li *ngFor="let todo of todos$ | async">
<input type="checkbox" [checked]="todo.completed" (click)="toggleTodo(todo.id)">
<span [class.completed]="todo.completed">{{ todo.text }}</span>
<button (click)="deleteTodo(todo.id)">Delete</button>
</li>
</ul>
<div>
<span>Active: {{ (activeTodos$ | async)?.length }}</span> |
<span>Completed: {{ (completedTodos$ | async)?.length }}</span>
</div>
`,
styles: [`
.completed { text-decoration: line-through; }
.error { color: red; }
`]
})
export class TodoListComponent implements OnInit {
todos$: Observable<Todo[]>;
activeTodos$: Observable<Todo[]>;
completedTodos$: Observable<Todo[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
constructor(private store: Store) {
this.todos$ = this.store.select(TodoSelectors.selectAllTodos);
this.activeTodos$ = this.store.select(TodoSelectors.selectActiveTodos);
this.completedTodos$ = this.store.select(TodoSelectors.selectCompletedTodos);
this.loading$ = this.store.select(TodoSelectors.selectLoading);
this.error$ = this.store.select(TodoSelectors.selectError);
}
ngOnInit() {
this.store.dispatch(TodoActions.loadTodos());
}
addTodo(text: string) {
if (text.trim()) {
this.store.dispatch(TodoActions.addTodo({ text }));
}
}
toggleTodo(id: number) {
this.store.dispatch(TodoActions.toggleTodo({ id }));
}
deleteTodo(id: number) {
this.store.dispatch(TodoActions.deleteTodo({ id }));
}
}
Best Practices for NgRx
To get the most out of NgRx, follow these best practices:
- Keep actions granular: Create specific actions for each event in your application.
- Use descriptive action types: Action types should describe exactly what happened.
- Keep reducers pure: Reducers should not have side effects or modify the state argument.
- Normalize complex data: Use the @ngrx/entity package for managing collections of entities.
- Use selectors for data access: Create reusable selectors to query state.
- Use the Redux DevTools: They're invaluable for debugging state changes.
When to Use NgRx
NgRx provides significant benefits but also adds complexity. Consider using NgRx when:
- Your application has complex state that is accessed by many components
- You need to manage multiple side effects (like API calls)
- You want to improve debugging and testing capabilities
- Your team is already familiar with Redux principles
For smaller applications, simpler state management solutions like services with BehaviorSubjects or component interaction patterns might be more appropriate.
Summary
NgRx is a powerful state management library for Angular applications that implements the Redux pattern. It helps you manage complex application state through a single store, pure functions for state transitions, and a unidirectional data flow.
We've covered:
- The core concepts of NgRx (Store, Actions, Reducers, Selectors, Effects)
- How to set up NgRx in an Angular application
- Building both a simple counter example and a more complex todo application
- Best practices and when to use NgRx
By following the patterns and practices outlined in this guide, you'll be able to build more maintainable and predictable Angular applications.
Additional Resources
Exercises
- Extend the counter example to include a "multiply by" action that accepts a number parameter.
- Add a filter feature to the Todo application to show "All", "Active", or "Completed" todos.
- Create a simple shopping cart application using NgRx, including actions for adding items, removing items, and checking out.
- Implement error handling in the Todo application to display error messages when API calls fail.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)