Mastering Angular 18 State Management using NgRx
bytebantz
Posted on September 10, 2024
State management in Angular ensures that data is consistently and efficiently shared across all parts of an application. Instead of each component managing its own data, a central store holds the state.
This centralization ensures that when data changes, all components automatically reflect the updated state, leading to consistent behavior and simpler code. It also makes the app easier to maintain and scale, as data flow is managed from a single source of truth.
In this article, we’ll explore how to implement state management in Angular using NgRx by building a simple shopping cart application. We’ll cover the core concepts of NgRx, such as the Store, Actions, Reducers, Selectors, and Effects, and demonstrate how these pieces fit together to manage the state of your application effectively.
State in Angular refers to the data your app needs to manage and display, like a shopping cart’s contents.
Why you need State Management
1. Consistency: It ensures that data is uniform across all components. When data changes in one place, the central store updates all relevant components automatically, preventing inconsistencies.
2. Simplified Data Flow: Instead of passing data between components manually, state management allows any component to access or update data directly from the central store, making the app’s data flow easier to manage and understand.
3. Easier Maintenance and Scalability: By centralizing data management, state management reduces code duplication and complexity. This makes the app easier to maintain, debug, and scale as it grows.
4. Performance Optimization: State management solutions often come with tools to optimize performance, such as selectively updating only the components that need to react to a change in state, rather than re-rendering the entire application.
How NgRx works
NgRx is a state management library for Angular that helps manage and maintain the state of your application in a predictable way.
1. Component
The component is where the user interacts with your app. It might be a button to add an item to the shopping cart.
Components and services are separated and don’t communicate with each other directly, instead services are used within effects thus creating an application structure different from a traditional Angular app.
2. Action
An action describes what happened and contains any necessary payload (data).
3. Reducer
Updates the state based on the action.
4. Store
The store is a centralized place that holds the entire state of your application.
5. Selector
Extracts data from the store for components.
6. Effects
Effects are where you handle logic that doesn’t belong in the reducer, like API calls.
7. Service
Services perform the actual business logic or API calls. Effects often use services to perform tasks like fetching data from a server.
When to Use NgRx
Use NgRx when your app’s complexity justifies it, but for straightforward apps, stick to simpler state management methods. Angular’s services, signals and @Input/@Output bindings between components are usually sufficient for managing state in less complex applications.
Example: Building an Add to Cart Feature with NgRx
1.Create a New Angular Project:
ng new shopping-cart
2. Install NGRX and Effects
To install NGRX and Effects, run the following command in your terminal:
ng add @ngrx/store@latest
ng add @ngrx/effects
3. Define the Product Model
Inside the src/app directory, create a file named product.model.ts
Define the Product interface to represent the structure of a product:
export interface Product {
id: string;
name: string;
price: number;
quantity: number;
}
4. Set Up State Management
Step 1: Create state Folder inside the src/app directory
Step 2: Define Cart Actions
Create cart.actions.ts in the state folder.
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Product } from '../product.model';
export const CartActions = createActionGroup({
source: 'Cart',
events: {
'Add Product': props<{ product: Product }>(),
'Remove Product': props<{ productId: string }>(),
'Update Quantity': props<{ productId: string; quantity: number }>(),
'Load Products': emptyProps,
},
});
export const CartApiActions = createActionGroup({
source: 'Cart API',
events: {
'Load Products Success': props<{ products: Product[] }>(),
'Load Products Failure': props<{ error: string }>(),
},
});
Step 3: Create Reducers
Create cart.reducer.ts in the state folder.
import { createReducer, on } from '@ngrx/store';
import { Product } from '../product.model';
import { CartActions, CartApiActions } from './cart.actions';
// Initial state for products and cart
export const initialProductsState: ReadonlyArray<Product> = [];
export const initialCartState: ReadonlyArray<Product> = [];
// Reducer for products (fetched from API)
export const productsReducer = createReducer(
initialProductsState,
on(CartApiActions.loadProductsSuccess, (_state, { products }) => products)
);
// Reducer for cart (initially empty)
export const cartReducer = createReducer(
initialCartState,
on(CartActions.addProduct, (state, { product }) => {
const existingProduct = state.find(p => p.id === product.id);
if (existingProduct) {
return state.map(p =>
p.id === product.id ? { ...p, quantity: p.quantity + product.quantity } : p
);
}
return [...state, product];
}),
on(CartActions.removeProduct, (state, { productId }) =>
state.filter(p => p.id !== productId)
),
on(CartActions.updateQuantity, (state, { productId, quantity }) =>
state.map(p =>
p.id === productId ? { ...p, quantity } : p
)
)
);
Step 4: Create Selectors
In the state folder, create cart.selectors.ts
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { Product } from '../product.model';
export const selectProducts = createFeatureSelector<ReadonlyArray<Product>>('products');
export const selectCart = createFeatureSelector<ReadonlyArray<Product>>('cart');
export const selectCartTotal = createSelector(selectCart, (cart) =>
cart.reduce((total, product) => total + product.price * product.quantity, 0)
);
Step 5: Create Effects
Create a new file cart.effects.ts in the state folder that listens for the Load Products action, uses the service to fetch products, and dispatches either a success or failure action.
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { ProductService } from '../product.service';
import { CartActions, CartApiActions } from './cart.actions';
import { catchError, map, mergeMap } from 'rxjs/operators';
import { of } from 'rxjs';
@Injectable()
export class CartEffects {
loadProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(CartActions.loadProducts),
mergeMap(() =>
this.productService.getProducts().pipe(
map(products => CartApiActions.loadProductsSuccess({ products })),
catchError(error => of(CartApiActions.loadProductsFailure({ error })))
)
)
)
);
constructor(
private actions$: Actions,
private productService: ProductService
) {}
}
5. Connect the State Management to Your App
In a file called app.config.ts, set up configurations for providing the store and effects to the application.
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideHttpClient } from '@angular/common/http';
import { cartReducer, productsReducer } from './state/cart.reducer';
import { provideEffects } from '@ngrx/effects';
import { CartEffects } from './state/cart.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideStore({
products: productsReducer,
cart: cartReducer
}),
provideHttpClient(),
provideEffects([CartEffects])
],
};
6. Create a Service to Fetch Products
In the src/app directory create product.service.ts to implement the service to fetch products
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Product } from './product.model';
@Injectable({ providedIn: 'root' })
export class ProductService {
getProducts(): Observable<Array<Product>> {
return of([
{ id: '1', name: 'Product 1', price: 10, quantity: 1 },
{ id: '2', name: 'Product 2', price: 20, quantity: 1 },
]);
}
}
7. Create the Product List Component
Run the following command to generate the component: ng generate component product-list
This component displays the list of products and allows adding them to the cart.
Modify the product-list.component.ts file:
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Product } from '../product.model';
import { selectProducts } from '../state/cart.selectors';
import { CartActions } from '../state/cart.actions';
@Component({
selector: 'app-product-list',
standalone: true,
templateUrl: './product-list.component.html',
styleUrls: ['./product-list.component.css'],
imports: [CommonModule],
})
export class ProductListComponent implements OnInit {
products$!: Observable<ReadonlyArray<Product>>;
constructor(private store: Store) {
}
ngOnInit(): void {
this.store.dispatch(CartActions.loadProducts()); // Dispatch load products action
this.products$ = this.store.select(selectProducts); // Select products from the store
}
onAddToCart(product: Product) {
this.store.dispatch(CartActions.addProduct({ product }));
}
}
Modify the product-list.component.html file:
<div *ngIf="products$ | async as products">
<div class="product-item" *ngFor="let product of products">
<p>{{product.name}}</p>
<span>{{product.price | currency}}</span>
<button (click)="onAddToCart(product)" data-test="add-button">Add to Cart</button>
</div>
</div>
8. Create the Shopping Cart Component
Run the following command to generate the component: ng generate component shopping-cart
This component displays the products in the cart and allows updating the quantity or removing items from the cart.
Modify the shopping-cart.component.ts file:
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Product } from '../product.model';
import { selectCart, selectCartTotal } from '../state/cart.selectors';
import { CartActions } from '../state/cart.actions';
@Component({
selector: 'app-shopping-cart',
standalone: true,
imports: [CommonModule],
templateUrl: './shopping-cart.component.html',
styleUrls: ['./shopping-cart.component.css'],
})
export class ShoppingCartComponent implements OnInit {
cart$: Observable<ReadonlyArray<Product>>;
cartTotal$: Observable<number>;
constructor(private store: Store) {
this.cart$ = this.store.select(selectCart);
this.cartTotal$ = this.store.select(selectCartTotal);
}
ngOnInit(): void {}
onRemoveFromCart(productId: string) {
this.store.dispatch(CartActions.removeProduct({ productId }));
}
onQuantityChange(event: Event, productId: string) {
const inputElement = event.target as HTMLInputElement;
let quantity = parseInt(inputElement.value, 10);
this.store.dispatch(CartActions.updateQuantity({ productId, quantity }));
}
}
Modify the shopping-cart.component.html file:
<div *ngIf="cart$ | async as cart">
<div class="cart-item" *ngFor="let product of cart">
<p>{{product.name}}</p><span>{{product.price | currency}}</span>
<input type="number" [value]="product.quantity" (input)="onQuantityChange($event, product.id)" />
<button (click)="onRemoveFromCart(product.id)" data-test="remove-button">Remove</button>
</div>
<div class="total">
Total: {{cartTotal$ | async | currency}}
</div>
</div>
Modify the shopping-cart.component.css file:
.cart-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.cart-item p {
margin: 0;
font-size: 16px;
}
.cart-item input {
width: 50px;
text-align: center;
}
.total {
font-weight: bold;
margin-top: 20px;
}
9. Put Everything Together in the App Component
This component will display the product list and the shopping cart
Modify the app.component.ts file:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProductListComponent } from './product-list/product-list.component';
import { ShoppingCartComponent } from './shopping-cart/shopping-cart.component';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
imports: [CommonModule, ProductListComponent, ShoppingCartComponent, NgIf],
})
export class AppComponent {}
Modify the app.component.html file:
<!-- app.component.html -->
<h2>Products</h2>
<app-product-list></app-product-list>
<h2>Shopping Cart</h2>
<app-shopping-cart></app-shopping-cart>
10. Running the Application
Finally, run your application using ng serve.
Now, you can add products to your cart, remove them, or update their quantities.
Conclusion
In this article, we built a simple shopping cart application to demonstrate the core concepts of NgRx, such as the Store, Actions, Reducers, Selectors, and Effects. This example serves as a foundation for understanding how NgRx works and how it can be applied to more complex applications.
As your Angular projects grow in complexity, leveraging NgRx for state management will help you maintain consistency across your application, reduce the likelihood of bugs, and make your codebase easier to maintain.
To get the code for the above project, click the link below:
https://github.com/anthony-kigotho/shopping-cart
Posted on September 10, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.