State management with NgRx
What is NgRx?
NgRx is a state management library for Angular applications. It provides a way to manage the state of your application in a predictable and centralized manner. NgRx is based on the Redux pattern, which revolves around a unidirectional data flow and immutable state.
NgRx is particularly useful for applications with complex state requirements, such as:
- Applications with shared state across multiple components.
- Applications that require undo/redo functionality.
- Applications with asynchronous data fetching and caching.
Why Use NgRx?
Managing state in large applications can become challenging as the application grows. Without a proper state management solution, you may encounter issues like:
- Tight coupling between components.
- Difficulty in debugging due to scattered state logic.
- Inconsistent state across the application.
NgRx solves these problems by providing:
- Centralised State: All application state is stored in a single, immutable store.
- Predictability: State changes are predictable because they are handled by pure functions called reducers.
- Debugging Tools: NgRx integrates with tools like the Redux DevTools, making it easier to debug and track state changes.
- Reactive Programming: NgRx leverages RxJS to handle asynchronous operations and state updates in a reactive way.
Core Concepts of NgRx
Before diving into implementation, let’s understand the core building blocks of NgRx:
- Store: The store is a centralized container that holds the application state. It is immutable, meaning the state cannot be modified directly.
- Actions: Actions are plain objects that describe an event or intention to change the state. They are dispatched to the store to trigger state changes.
- Reducers: Reducers are pure functions that take the current state and an action as input and return a new state. They define how the state should change in response to an action.
- Selectors: Selectors are functions used to query and retrieve specific pieces of state from the store.
- Effects: Effects handle side effects, such as API calls or other asynchronous operations, and dispatch new actions based on the results.
Setting Up NgRx in an Angular Application
Let’s walk through the steps to set up and use NgRx in an Angular application.
Step 1: Install NgRx
To get started, install the required NgRx packages:
npm install @ngrx/store @ngrx/effects @ngrx/store-devtools @ngrx/entity @ngrx/router-store
Step 2: Define the State
Create an interface to define the shape of your application state. For example, if you’re managing a list of products:
export interface Product {
id: number;
name: string;
price: number;
}
export interface AppState {
products: Product[];
}
Step 3: Create Actions
Actions represent events that can change the state. Define actions for adding, removing, or loading products:
import { createAction, props } from '@ngrx/store';
import { Product } from './product.model';
export const loadProducts = createAction('[Product] Load Products');
export const loadProductsSuccess = createAction(
'[Product] Load Products Success',
props<{ products: Product[] }>()
);
export const addProduct = createAction(
'[Product] Add Product',
props<{ product: Product }>()
);
export const removeProduct = createAction(
'[Product] Remove Product',
props<{ productId: number }>()
);
Step 4: Create a Reducer
Reducers handle state changes based on the dispatched actions. Use the createReducer
function to define how the state should change:
import { createReducer, on } from '@ngrx/store';
import { Product } from './product.model';
import { addProduct, removeProduct, loadProductsSuccess } from './product.actions';
export const initialState: Product[] = [];export const productReducer = createReducer(
initialState,
on(loadProductsSuccess, (state, { products }) => [...products]),
on(addProduct, (state, { product }) => [...state, product]),
on(removeProduct, (state, { productId }) =>
state.filter(product => product.id !== productId)
)
);
Step 5: Register the Store
Register the reducer in the AppModule
using the StoreModule.forRoot
method:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { StoreModule } from '@ngrx/store';
import { productReducer } from './state/product.reducer';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
StoreModule.forRoot({ products: productReducer })
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
Step 6: Create Selectors
Selectors are used to retrieve specific pieces of state from the store. Define selectors for the product state:
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { Product } from './product.model';
export const selectProducts = createFeatureSelector<Product[]>('products');export const selectProductById = (productId: number) =>
createSelector(selectProducts, products =>
products.find(product => product.id === productId)
);
Step 7: Use the Store in Components
Inject the Store
service into your components to dispatch actions and select state:
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Product } from './product.model';
import { addProduct, loadProducts } from './state/product.actions';
import { selectProducts } from './state/product.selectors';
@Component({
selector: 'app-product-list',
template: `
<div *ngFor="let product of products\$ | async">
{{ product.name }} - \${{ product.price }}
</div>
<button (click)="addNewProduct()">Add Product</button>
`
})
export class ProductListComponent implements OnInit {
products\$: Observable<Product[]>; constructor(private store: Store) {
this.products\$ = this.store.select(selectProducts);
} ngOnInit() {
this.store.dispatch(loadProducts());
} addNewProduct() {
const newProduct: Product = { id: 3, name: 'New Product', price: 100 };
this.store.dispatch(addProduct({ product: newProduct }));
}
}
Step 8: Handle Side Effects with Effects
Use NgRx Effects to handle side effects like API calls. For example, fetching products from an API:
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';
import { ProductService } from './product.service';
import { loadProducts, loadProductsSuccess } from './state/product.actions';
@Injectable()
export class ProductEffects {
loadProducts\$ = createEffect(() =>
this.actions\$.pipe(
ofType(loadProducts),
mergeMap(() =>
this.productService.getProducts().pipe(
map(products => loadProductsSuccess({ products })),
catchError(() => of({ type: '[Product] Load Products Failure' }))
)
)
)
); constructor(private actions\$: Actions, private productService: ProductService) {}
}
Register the ProductEffects
in the AppModule
:
import { EffectsModule } from '@ngrx/effects';
import { ProductEffects } from './state/product.effects';
@NgModule({
imports: [
EffectsModule.forRoot([ProductEffects])
]
})
export class AppModule {}
Conclusion
NgRx provides a powerful and scalable solution for managing state in Angular applications. By following the principles of unidirectional data flow and immutability, NgRx makes it easier to build predictable and maintainable applications. While the initial setup may seem complex, the benefits of centralized state management, debugging tools, and reactive programming make it worth the effort.