Signal in a service for state management in Angular

Reading Time: 6 minutes

Loading

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 use state management libraries or “Signal in a Service” to manage states.

In this blog post, I implement the “Signal in a Service” to create a cart store that tracks selected products, subtotal, discount, total, and promotional code. Moreover, I use the facade pattern to hide the store’s complexity so that swapping between state management solutions has limited effects on the cart components. The components inject the cart facade to obtain the underlying store data and display the values in the HTML template.

let's go

Create a cart state

// product.interface.ts

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

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

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

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

export interface CartStoreState {
  promoCode: string;
  cart: CartItem[],
}

CartStoreState manages the state of the shopping cart, which consists of a promotional code and products in the cart. CartStore uses this interface to maintain and display the values in different cart components.

Create a cart store

// cart.store.ts

import { CartStoreState } from '../states/cart-store.state';
import { Injectable, computed, signal } from '@angular/core';
import { Product } from '../../products/interfaces/product.interface';
import { CartItem } from '../types/cart-item.type';

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

function updateCart(cart: CartItem[], productId: number, quantity: number): CartItem[] {
  if (quantity <= 0) {
    return cart.filter((item) => item.id !== productId);
  }
    
  return cart.map((item) => 
    item.id === productId ? { ...item, quantity} : item 
  );
}

function addCart(oldCart: CartItem[], idx: number, product: Product, quantity: number): CartItem[] {
  if (idx >= 0) {
    return oldCart.map((item, i) => {
      if (i === idx) {
        return {
          ...item,
          quantity: item.quantity + quantity,
        }
      }
      return item;
    });
  }
  
  return [...oldCart, { ...product, quantity } ];
}

@Injectable({
  providedIn: 'root'
})
export class CartStore {
  state = signal(initialState);

  cart = computed(() => this.state().cart);
  promoCode = computed(() => this.state().promoCode);

  discountPercent = computed(() => {
    const promoCode = this.state().promoCode;
    if (promoCode === 'DEVFESTHK2023') {
        return 0.1;
    } else if (promoCode === 'ANGULARNATION') {
      return 0.2;
    }

    return 0;
  });

  summary = computed(() => {
    const results = this.state().cart.reduce(({ quantity, subtotal }, item) => ({ 
        quantity: quantity + item.quantity,
        subtotal: subtotal + item.price * item.quantity
      }), { quantity: 0, subtotal: 0 });

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

    return { 
      quantity, 
      subtotal: subtotal.toFixed(2),
      discount: discount.toFixed(2),
      total: total.toFixed(2),
    }
  });

 updatePromoCode(promoCode: string): void {
    this.state.update((oldState) => ({ ...oldState, promoCode }));
  }

  buy(idx: number, product: Product, quantity: number): void {
    this.state.update(({ cart: oldCart, promoCode}) => {
      const newCart = addCart(oldCart, idx, product, quantity);
      return {
        promoCode,
        cart: newCart,
      }
    });
  }

  update(id: number, quantity: number): void {
    this.state.update(({ promoCode, cart: oldCart }) => {
      const cart = updateCart(oldCart, id, quantity);
      return { 
        promoCode,
        cart 
      };
    });
  }
}

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

cart = computed(() => this.state().cart)

Derive a computed signal to return the shopping cart.

promoCode = computed(() => this.state().promoCode)

Derive a computed signal to return the promotional code.

discountPercent = computed(() => {
    if (this.promoCode() === 'DEVFESTHK2023') {
        return 0.1;
    } else if (this.promoCode() === 'ANGULARNATION') {
      return 0.2;
    }

    return 0;
  });

Calculate the percentage of the discount based on the promotional code.

summary = computed(() => {
   .....

    return { 
      quantity, 
      subtotal: subtotal.toFixed(2),
      discount: discount.toFixed(2),
      total: total.toFixed(2),
    }
  });

summary is a computed signal that derives quantity, subtotal, discount, and total amount of the shopping cart.

The updatePromoCode method updates the promotional code and refreshes the discount percentage and total. On the other hand, the buy and update methods update the content of the cart array.

Define a Cart Facade

// cart.facade.ts

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

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

  cart = this.store.cart;
  discountPercent = this.store.discountPercent;
  summary = this.store.summary;
  promoCode = 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.update(id, 0);
  }

  updateCart(id: number, quantity: number) {
    this.store.update(id, quantity);
  }
}

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 solutions. I use built-in Signal in this demo but can easily replace it with TanStack Store or NgRx Signal Store. The facade executes Inject(CartStore) to inject an instance of CartStore, declare members to assign the store signals, and provides 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()">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() {
    return this.cartFacade.updatePromoCode(this.promoCode());
  }
}

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()">Update</button>
        <button (click)="delete()">X</button>
      </p>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CartItemComponent {
  cartFacade = inject(CartFacade);

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

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

  update() {
    return this.cartFacade.updateCart(this.item().id, this.quantity());
  }
}

CartItemComponent is a component that displays the product information and quantity in 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;
}

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);
    this.cartFacade.addCart(idx, product, this.quantity());
  }
}

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 “Signal in a Service” 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 Signal 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:

  1. Stackblitz Demo: https://stackblitz.com/edit/stackblitz-starters-tem6vq?file=src%2Fcarts%2Fstores%2Fcart.store.ts
  2. Github Repo: https://github.com/railsstudent/ng-state-management-showcase/tree/main/projects/ng-signal-demo
  3. Documentation: Signal guide in angular.dev