How NgRx Works
NgRx works by breaking down the application’s state management into several key components:
- Store: A centralized location that holds the state of the application.
- Actions: Objects that describe an event or change in the application’s state.
- Reducers: Pure functions that specify how the state should change in response to an action.
- Selectors: Functions used to retrieve specific slices of the state.
- Effects: Handle side effects such as API calls or other asynchronous operations.
NgRx Flow
- Dispatch an Action: Actions are dispatched to indicate that something has occurred in the application.
- Reducer Updates the State: The reducer listens for the action and updates the state accordingly.
- Selectors Retrieve Data: Components use selectors to get the required data from the store.
- Effects Perform Side Effects: For tasks like fetching data from an API, effects are triggered and return new actions.
Visual Representation
Action -> Reducer -> Store -> Component
-> Effect -> API -> Store
How to Use NgRx Store with Angular
Now let’s go step by step to set up and use the NgRx store in an Angular application.
Step 1: Install NgRx
First, install the required NgRx packages using npm:
npm install @ngrx/store @ngrx/effects @ngrx/store-devtools
Step 2: Define the State
Create an interface to define the shape of your state. For example, for a simple todo application:
export interface Todo {
id: number;
title: string;
completed: boolean;
}
export interface AppState {
todos: Todo[];
}
Step 3: Create Actions
Define actions to describe changes in the state. Use createAction from NgRx:
import { createAction, props } from '@ngrx/store';
export const addTodo = createAction(
'[Todo] Add Todo',
props<{ todo: Todo }>()
);
export const removeTodo = createAction(
'[Todo] Remove Todo',
props<{ id: number }>()
);
export const loadTodos = createAction('[Todo] Load Todos');
Step 4: Create a Reducer
Reducers specify how the state changes based on actions:
import { createReducer, on } from '@ngrx/store';
import { addTodo, removeTodo, loadTodos } from './todo.actions';
import { Todo } from './todo.state';
const initialState: Todo[] = [];
export const todoReducer = createReducer(
initialState,
on(addTodo, (state, { todo }) => [...state, todo]),
on(removeTodo, (state, { id }) => state.filter(todo => todo.id !== id))
);
Step 5: Register the Store
Add the reducer to the StoreModule in your Angular application:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { StoreModule } from '@ngrx/store';
import { todoReducer } from './todo.reducer';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
StoreModule.forRoot({ todos: todoReducer })
],
bootstrap: [AppComponent]
})
export class AppModule {}
Step 6: Use Selectors in Components
Use selectors to retrieve data from the store in a component:
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState, Todo } from './todo.state';
@Component({
selector: 'app-root',
template: `
<ul>
<li *ngFor="let todo of todos$ | async">
{{ todo.title }}
</li>
</ul>
`
})
export class AppComponent {
todos$ = this.store.select(state => state.todos);
constructor(private store: Store<AppState>) {}
}
Step 7: Handle Side Effects with Effects
Use effects for asynchronous operations like fetching data:
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { loadTodos } from './todo.actions';
import { of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
@Injectable()
export class TodoEffects {
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(loadTodos),
switchMap(() => of([{ id: 1, title: 'Learn NgRx', completed: false }])),
map(todos => ({ type: '[Todo API] Todos Loaded', todos }))
)
);
constructor(private actions$: Actions) {}
}
Simple Service-Based State Management
State management in Angular can be done in many ways, & one of the simplest methods is using Angular services. Services in Angular are singleton objects, meaning they are created once & shared across the entire application. This makes them perfect for managing shared state.
Let’s say you are building a to-do list application. You need to store & manage the list of tasks in one place so that all components can access & update it. This is where a service becomes very useful.
Step 1: Create a Service
First, generate a service using Angular CLI:
ng generate service todo
This will create a `todo.service.ts` file.
Step 2: Define the State in the Service
Inside the service, you can define a variable to store the state. For example, let’s create an array to store the to-do tasks:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class TodoService {
private tasks: string[] = [];
getTasks(): string[] {
return this.tasks;
}
addTask(task: string): void {
this.tasks.push(task);
}
removeTask(index: number): void {
this.tasks.splice(index, 1);
}
}
Here, `tasks` is the state. The `getTasks`, `addTask`, & `removeTask` methods are used to read & update the state.
Step 3: Use the Service in Components
Now, let’s use this service in a component. First, generate a component:
ng generate component todo-list
In the `todo-list.component.ts` file, inject the service & use it to manage the state:
import { Component } from '@angular/core';
import { TodoService } from '../todo.service';
@Component({
selector: 'app-todo-list',
templateUrl: './todo-list.component.html',
styleUrls: ['./todo-list.component.css'],
})
export class TodoListComponent {
tasks: string[] = [];
newTask: string = '';
constructor(private todoService: TodoService) {}
ngOnInit(): void {
this.tasks = this.todoService.getTasks();
}
addTask(): void {
if (this.newTask.trim()) {
this.todoService.addTask(this.newTask);
this.tasks = this.todoService.getTasks();
this.newTask = '';
}
}
removeTask(index: number): void {
this.todoService.removeTask(index);
this.tasks = this.todoService.getTasks();
}
}
In the `todo-list.component.html` file, display the tasks & provide a way to add/remove them:
<div>
<input [(ngModel)]="newTask" placeholder="Add a new task" />
<button (click)="addTask()">Add</button>
</div>
<ul>
<li ngFor="let task of tasks; let i = index">
{{ task }} <button (click)="removeTask(i)">Remove</button>
</li>
</ul>
Explanation
- The `TodoService` holds the state (`tasks` array) & provides methods to update it.
- The `TodoListComponent` uses the service to read & modify the state.
- This approach is simple & works well for small applications.
Angular Signals: The New State Management Approach
Angular recently introduced a new feature called Signals to make state management more efficient & reactive. Signals are a way to track changes in data & automatically update the UI when the data changes. They are designed to be simple, lightweight, & highly performant.
Unlike traditional state management methods, Signals provide a more declarative way to handle state. They are especially useful for managing complex state in large applications.
Step 1: Understanding Signals
A Signal is a wrapper around a value that can notify interested consumers when that value changes. You can create a Signal using the `signal` function & update it using the `set` or `update` methods.
Step 2: Creating a Signal
Let’s continue with the to-do list example. First, update your Angular project to the latest version to use Signals (if not already done).
In the `todo.service.ts` file, replace the old state management with Signals:
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class TodoService {
private tasks = signal<string[]>([]);
getTasks() {
return this.tasks();
}
addTask(task: string): void {
this.tasks.update((currentTasks) => [...currentTasks, task]);
}
removeTask(index: number): void {
this.tasks.update((currentTasks) => {
const updatedTasks = [...currentTasks];
updatedTasks.splice(index, 1);
return updatedTasks;
});
}
}
Here, `tasks` is now a Signal. The `signal` function initializes it with an empty array. The `update` method is used to modify the Signal’s value.
Step 3: Using Signals in Components
In the `todo-list.component.ts` file, update the component to use Signals:
import { Component } from '@angular/core';
import { TodoService } from '../todo.service';
@Component({
selector: 'app-todo-list',
templateUrl: './todo-list.component.html',
styleUrls: ['./todo-list.component.css'],
})
export class TodoListComponent {
tasks = this.todoService.getTasks();
newTask: string = '';
constructor(private todoService: TodoService) {}
addTask(): void {
if (this.newTask.trim()) {
this.todoService.addTask(this.newTask);
this.newTask = '';
}
}
removeTask(index: number): void {
this.todoService.removeTask(index);
}
}
In the `todo-list.component.html` file, bind the Signal directly to the template:
<div>
<input [(ngModel)]="newTask" placeholder="Add a new task" />
<button (click)="addTask()">Add</button>
</div>
<ul>
<li ngFor="let task of tasks; let i = index">
{{ task }} <button (click)="removeTask(i)">Remove</button>
</li>
</ul>
Explanation
- Signals automatically notify the template when the state changes, so you don’t need to manually update the UI.
- The `update` method ensures immutability, which is crucial for maintaining predictable state.
- Signals are more efficient than traditional methods because they only update the parts of the UI that depend on the changed state.
Best Practices & Recommendations
State management is a crucial part of building scalable & maintainable Angular applications. While Angular provides multiple ways to handle state, it’s important to follow best practices to avoid common pitfalls. Let’s discuss some of the recommendations to help you manage state effectively:
1. Keep State Centralized
Centralizing the state makes it easier to manage & debug. Instead of scattering state across multiple components, store it in a service or a dedicated state management library like NgRx or Akita.
For example, in our to-do list application, we used a service (`TodoService`) to centralize the state. This ensures that all components access & modify the same state.
2. Use Immutable Data Structures
Immutable data structures prevent accidental changes to the state, making it easier to track changes & debug issues. In our Signal example, we used the `update` method to ensure immutability:
this.tasks.update((currentTasks) => [...currentTasks, task]);
This creates a new array instead of modifying the existing one.
3. Avoid Overusing Signals
While signals are powerful, they are not always necessary. For simple state management, services or even component-level state might be sufficient. Use Signals when you need fine-grained reactivity or have complex state dependencies.
4. Leverage Angular’s Built-in Features
Angular provides built-in features like `@Input`, `@Output`, & `ngOnChanges` for component communication. Use these features for simple parent-child component interactions before introducing a state management library.
5. Use a State Management Library for Complex Applications
For large applications with complex state, consider using a state management library like NgRx or Akita. These libraries provide tools like actions, reducers, & effects to manage state in a predictable way.
6. Optimize Performance
State management can impact performance if not done carefully. Use Angular’s `ChangeDetectionStrategy.OnPush` to reduce unnecessary change detection cycles. For example:
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-todo-list',
templateUrl: './todo-list.component.html',
styleUrls: ['./todo-list.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoListComponent {
// Component logic here
}
This ensures that the component only updates when its inputs change.
7. Write Tests for State Management
State management logic should be thoroughly tested to ensure it works as expected. Use Angular’s testing utilities to write unit tests for your services & components.
For example, here’s a simple test for the `TodoService`:
import { TestBed } from '@angular/core/testing';
import { TodoService } from './todo.service';
describe('TodoService', () => {
let service: TodoService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(TodoService);
});
it('should add a task', () => {
service.addTask('Learn Angular');
expect(service.getTasks()).toContain('Learn Angular');
});
it('should remove a task', () => {
service.addTask('Learn Angular');
service.removeTask(0);
expect(service.getTasks()).not.toContain('Learn Angular');
});
});
8. Document Your State Management Strategy
Documenting how state is managed in your application helps new developers understand the codebase quickly. Include details like where the state is stored, how it’s updated, & which components depend on it.
Frequently Asked Questions
What is the purpose of NgRx in Angular applications?
NgRx provides a predictable state management solution, making it easier to handle complex state changes, asynchronous operations, and debugging.
What are the core components of NgRx?
The main components of NgRx are:
- Store: Holds the state.
- Actions: Define events.
- Reducers: Modify the state.
- Selectors: Retrieve state slices.
- Effects: Handle side effects.
Can NgRx be used with small applications?
Yes, but it is generally more useful for medium to large-scale applications where managing state manually can become cumbersome.
Conclusion
In this article, we discussed state management in Angular using NgRx. We covered the basics of NgRx, how it works, and instructions for integrating it into an Angular application. With examples of actions, reducers, and effects, we can now implement state management effectively in our projects.
You can also check out our other blogs on Code360.