DEV Community

Connie Leung
Connie Leung

Posted on • Originally published at blueskyconnie.com

State Management in Angular using NgRx Signal Store

Introduction

Many companies and developers use Angular to build Enterprise applications, which are so large that they must manage a lot of data. To maintain the applications in scale, developers tend to use state management libraries or the Angular Signal API to manage states.

In this blog post, I want to use the NgRx Signal Store to create a cart store to maintain data. The components inject the cart store to obtain state properties and display the values in the HTML template. Moreover, I use the facade pattern to hide the details of the store such that swapping between state management libraries has limited effects on the cart components.

Install new dependencies

npm install @ngrx/signals
Enter fullscreen mode Exit fullscreen mode

Create a cart state

// product.interface.ts

export interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
  category: string;
  image: string;
}
Enter fullscreen mode Exit fullscreen mode
// cart-item.type.ts

import { Product } from '../../products/interfaces/product.interface';

export type CartItem = Product & { quantity: number };
Enter fullscreen mode Exit fullscreen mode
// cart-store.state.ts

import { CartItem } from "../types/cart.type";

export interface CartStoreState {
  promoCode: string;
  discountPercent: number;
  cart: CartItem[],
}
Enter fullscreen mode Exit fullscreen mode

CartStoreState manages the state of the shopping cart, and it consists of promotional code, discount, and items in the cart. NgRx Signal Store uses this interface to maintain the values and display them in different cart components.

Create a cart store

// cart.store.ts

import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { CartStoreState } from '../states/cart-store.state';
import { computed } from '@angular/core';
import { Product } from '../../products/interfaces/product.interface';
import { CartItem } from '../types/cart.type';

const initialState: CartStoreState = {
  promoCode: '',
  cart: [],
}

function deleteFromCart(state: CartStoreState, id: number): CartItem[] {
  const updatedCart = state.cart.filter((item) => item.id !== id);

  return updatedCart;
}

export const CartStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ promoCode }) => ({
    discountPercent: computed(() => {
      if (promoCode() === 'DEVFESTHK2023') {
        return 0.1;
      } else if (promoCode() === 'ANGULARNATION') {
        return 0.2;
      }

      return 0;
    }),
  })),
  withComputed(({ discountPercent, cart }) => {
    return {
      summary: computed(() => {
        const results = cart().reduce(({ quantity, subtotal }, item) => {
          const newQuantity = quantity + item.quantity;
          const newSubtotal = subtotal + item.price * item.quantity;

          return { 
            quantity: newQuantity,
            subtotal: newSubtotal
          }
        }, { quantity: 0, subtotal: 0 });

        const { subtotal, quantity } = results;
        const discount = subtotal * discountPercent();
        const total = subtotal - discount; 

        return { 
          quantity, 
          subtotal: subtotal.toFixed(2),
          discount: discount.toFixed(2),
          total: total.toFixed(2),
        }
      }),
    }
  }),
  withMethods((store) => ({
    updatePromoCode(promoCode: string): void {
      patchState(store, (state) => ({ promoCode }))
    },
    buy(idx: number, product: Product, quantity: number): void {
      patchState(store, (state) => {
        let newCart: CartItem[] = [];
        if (idx >= 0) {
          newCart = state.cart.map((item, i) => {
            if (i === idx) {
              return {
                ...item,
                quantity: item.quantity + quantity,
              }
            }
            return item;``
          });
        } else {
          newCart = [...state.cart, { ...product, quantity } ];
        }

        return {
          cart: newCart,
        }
      })
    },
    remove(id: number): void {
      patchState(store, (state) => {
        const cart = deleteFromCart(state, id);
        return { cart };
      });
    },
    update(id: number, quantity: number): void {  
      patchState(store, (state) => {
        if (quantity <= 0) {
          const cart = deleteFromCart(state, id);
          return { cart };
        } else {
          const cart = state.cart.map((item) => 
            item.id === id ? { ...item, quantity} : item 
          );

          return { cart };
        }
      });
    }
  }))
);
Enter fullscreen mode Exit fullscreen mode

Create a CartStore store that initializes the state, defines computed signal and signal methods.

  • { providedIn: 'root' } provides the CartStore store globally such that the store is available in the entire application.
  • withState(initialState) intializes the state to a blank promotional code, 0 discount, and an empty cart.
  • withComputed adds two computed signals to the store: discountPercent derives the percentage of discount based on the promotional code. summary calculates the quantity, discount, subtotal, and total based on the items and promotional code.
  • withMethods adds methods to the store to add, delete, and update the state of the cart. The utility function patchState allows me to patch a piece of the state, not the entire one.

Define a Cart Facade

// cart.facade.ts

import { Injectable, Signal, inject } from "@angular/core";
import { CartStore } from "../stores/cart.store";
import { Product } from "../../products/interfaces/product.interface";
import { CartItem } from "../types/cart.type";

@Injectable({
  providedIn: 'root'
})
export class CartFacade {
  private store = inject(CartStore);

  get cart(): Signal<CartItem[]> {
    return this.store.cart;
  }

  get discountPercent(): Signal<number> {
    return this.store.discountPercent;
  }

  get summary() {
    return this.store.summary;
  }

  get promoCode() {
    return this.store.promoCode;
  }

  updatePromoCode(promoCode: string) {
    this.store.updatePromoCode(promoCode);
  }

  addCart(idx: number, product: Product, quantity: number) {
    this.store.buy(idx, product, quantity);
  }

  deleteCart(id: number) {
    this.store.remove(id);
  }

  updateCart(id: number, quantity: number) {
    this.store.update(id, quantity);
  }
}
Enter fullscreen mode Exit fullscreen mode

CartFacade is a service that encapsulates the cart store. The facade centralizes the logic of statement management, making it easy for me to swap between state management libraries. I use NgRx Signal Store in this demo, but I can easily replace it with TanStack Store or Angular Signal API. The facade executes Inject(CartStore) to create an instance of CartStore, and provides the getters to return the properties and methods that delegate the responsibility to the store methods.

The facade is completed, and I can apply state management to different cart components to display the store properties.

Access the store in the cart components

// cart.component.ts

// omit import statements for brevity 

@Component({
  selector: 'app-cart',
  standalone: true,
  imports: [CartItemComponent, CartTotalComponent, FormsModule],
  template: `
    @if (cart().length > 0) {
      <div class="cart">
        <div class="row">
          <p style="width: 10%">Id</p>
          <p style="width: 20%">Title</p>
          <p style="width: 40%">Description</p>
          <p style="width: 10%">Price</p>
          <p style="width: 10%">Qty</p> 
          <p style="width: 10%">&nbsp;</p> 
        </div>

        @for (item of cart(); track item.id) {
          <app-cart-item [item]="item" [quantity]="item.quantity" />
        }
        <app-cart-total />
        <span>Promotion code: </span>
        <input [(ngModel)]="promoCode" />
        <button (click)="updatePromoCode(promoCode())">Apply</button>
      </div>
    } @else {
      <p>Your cart is empty, please buy something.</p>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartComponent {
  cartFacade = inject(CartFacade);
  promoCode = signal(this.cartFacade.promoCode());
  cart = this.cartFacade.cart;

  updatePromoCode(code: string) {
    return this.cartFacade.updatePromoCode(code);
  }
}
Enter fullscreen mode Exit fullscreen mode

CartComponent injects the CartFacade and accesses the properties. The input box displays the promotional code, and the component iterates the cart to display the cart items.

// cart-item.component.ts

@Component({
  selector: 'app-cart-item',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="row">
      <p style="width: 10%">{{ item().id }}</p>
      <p style="width: 20%">{{ item().title }}</p>
      <p style="width: 40%">{{ item().description }}</p>
      <p style="width: 10%">{{ item().price }}</p>
      <p style="width: 10%">
        <input style="width: 50px;" type="number" min="1" [(ngModel)]="quantity" />
      </p>
      <p style="width: 10%">
        <button class="btnUpdate" (click)="update(item().id, quantity())">Update</button>
        <button (click)="delete(item().id)">X</button>
      </p>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CartItemComponent {
  cartFacade = inject(CartFacade);

  item = input.required<CartItem>();
  quantity = model(0);

  delete(id: number) {
    return this.cartFacade.deleteCart(id);
  }

  update(id: number, quantity: number) {
    return this.cartFacade.updateCart(id, quantity);
  }
}
Enter fullscreen mode Exit fullscreen mode

CartItemComponent is a component that displays the product information and the quantity on a single row. Each row has update and delete buttons to modify and delete the quantity respectively.

// cart-total.component.ts

@Component({
  selector: 'app-cart-total',
  standalone: true,
  imports: [PercentPipe],
  template: `
    <div class="summary">
      <div class="row">
        <div class="col">Qty: {{ summary().quantity }}</div>
        <div class="col">Subtotal: {{ summary().subtotal }}</div>
      </div>
      @if (discountPercent() > 0) {
        <div class="row">
          <div class="col">Minus {{ discountPercent() | percent:'2.2-2' }}</div> 
          <div class="col">Discount: {{ summary().discount }}</div>
        </div>
      }
      <div class="row">
        <div class="col">&nbsp;</div> 
        <div class="col">Total: {{ summary().total }}</div>
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CartTotalComponent {
  cartFacade = inject(CartFacade);

  discountPercent = this.cartFacade.discountPercent;
  summary = this.cartFacade.summary;
}
Enter fullscreen mode Exit fullscreen mode

CartTotalComponent is a component that displays the percentage of discount, the quantity, the amount of discount, the subtotal and the total.

// product-details.component.ts

@Component({
  selector: 'app-product-details',
  standalone: true,
  imports: [TitleCasePipe, FormsModule, RouterLink],
  template: `
    <div>
      @if (product(); as data) {
        @if (data) {
          <div class="product">
            <div class="row">
              <img [src]="data.image" [attr.alt]="data.title || 'product image'" width="200" height="200" />
            </div>
            <div class="row">
              <span>Id:</span>
              <span>{{ data.id }}</span>
            </div>
            <div class="row">
              <span>Category: </span>
              <a [routerLink]="['/categories', data.category]">{{ data.category | titlecase }}</a>
            </div>
            <div class="row">
              <span>Description: </span>
              <span>{{ data.description }}</span>
            </div>
            <div class="row">
              <span>Price: </span>
              <span>{{ data.price }}</span>
            </div> 
          </div>
          <div class="buttons">
            <input type="number" class="order" min="1" [(ngModel)]="quantity" />
            <button (click)="addItem(data)">Add</button>
          </div>
        }
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductDetailsComponent {
  id = input<number | undefined, string | undefined>(undefined, {
    transform: (data) => {
      return typeof data !== 'undefined' ? +data : undefined;
    }
  });

  cartFacade = inject(CartFacade);
  categoryFacade = inject(CategoryFacade);
  quantity = signal(1);

  cart = this.cartFacade.cart;

  product = toSignal(toObservable(this.id)
    .pipe(switchMap((id) => this.getProduct(id))), {
    initialValue: undefined
  });

  async getProduct(id: number | undefined) {
    try {
      if (!id) {
        return undefined;
      }

      return this.categoryFacade.products().find((p) => p.id === id);
    } catch {
      return undefined;
    }
  }

  addItem(product: Product) {
    const idx = this.cart().findIndex((item) => item.id === product.id);
    console.log('addItem', idx);
    this.cartFacade.addCart(idx, product, this.quantity());
  }
}
Enter fullscreen mode Exit fullscreen mode

ProductDetailsComponent displays the production information and an Add button to add the product to the shopping cart. When users click the button, the facade invokes the addCart method to update the state of the cart store. The facade increments the quantity of the product when it exists in the cart. The facade appends a new product to the cart state of the store when it is not found.

The demo successfully used the NgRx Signal Store to manage the state of the shopping cart. When components want to access the store, they do so through the cart facade.

The following Stackblitz Demo shows the final result:

This concludes my blog post about using Angular and NgRx Signal Store to build the cart store for my simple online shop demo. I hope you like the content and continue to follow my learning experience in Angular, NestJS, and other technologies.

Resources:

Top comments (4)

Collapse
 
jangelodev profile image
João Angelo

Hi Connie Leung,
Your tips are very useful.
Your series is very good
Thanks for sharing.

Collapse
 
railsstudent profile image
Connie Leung

Thank you for your kind word

Collapse
 
santhanam87 profile image
Santhanam Elumalai

Nicely written, If I had the business context upfront it would have been much easier for me to go through the code.

Collapse
 
railsstudent profile image
Connie Leung

It is a simple online shop demo that displays products for users to add them to a shopping cart.

I use different libraries to manage the state of the shopping cart, products, and categories.