rx-angular/state - a library for managing states of an Angular application
Tags: angular, tutorial, web3, typescript
Published: 2024-05-15 00:38:08

##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 such as the rx-angular/state library or the Angular Signal API to manage states.

In this blog post, I create a cart store using rx-angular/state library that updates the state of the cart and promotional code. The store instantiates an instance of RxState with an initial state and the setup function also connects signals to different state properties. Moreover, I apply the facade pattern to hide the store's complexity so that swapping between state management solutions is transparent to the cart components. The components inject the cart facade to access the underlying signals and display the values in the HTML templates.

###Install dependencies

```bash
npm i --save-exact @rx-angular/state
```

###Create a cart state

```typescript
// product.interface.ts

export interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
  category: string;
  image: string;
}
```

```typescript
// cart-item.type.ts

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

export type CartItem = Product & { quantity: number };
```

```typescript
// cart-store.state.ts

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

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

`CartStoreState` manages the shopping cart's state, consisting of a promotional code and selected products. CartStore uses this interface to maintain and display the values in different cart components.

###Create a cart store

```typescript
// cart.store.ts

import { Injectable, Signal, signal } from '@angular/core';
import { rxState } from '@rx-angular/state';
import { CartStoreState } from '../states/cart-store.state';
import { BuyCartItem, OrderCartItem } from '../types/order-cart-item.type';

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

function buy({ cart }: CartStoreState, { idx, product, quantity }: BuyCartItem) {
  if (idx >= 0) {
    cart[idx] = {
      ...cart[idx],
      quantity: cart[idx].quantity + quantity,
    }
    return cart;
  }

  return [...cart, { ...product, quantity }];
}

@Injectable({
  providedIn: 'root'
})
export class CartStore {
  orderCartItem = signal<OrderCartItem | null>(null);
  promoCode = signal<string>('');

  private state = rxState<CartStoreState>(({ set, connect, select }) => {
    // set initial statement
    set(initialState);
    connect('cart', this.orderCartItem, (state, value) => {
      if (!value) {
        return state.cart;
      }

      const isRemove = value.action === 'remove' || value.action === 'update' && value.quantity <= 0;
      if (value.action === 'buy') {
        return buy(state, value);
      } else if (value.action === 'update' && value.quantity > 0) {
        return state.cart.map((item) => 
          item.id === value.id ? { ...item, quantity: value.quantity } : item);
      } else if (isRemove) {
        return state.cart.filter((item) => item.id !== value.id);
      }

      return state.cart;
    });
    connect('promoCode', this.promoCode)
  });

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

    return 0;
  }) as Signal<number>;

  summary = this.state.computed(({ cart }) => {
    const results = 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),
    }
  });
}
```

Create a CartStore store that initializes the state, connects signals to different state properties, and derives computed signals.

```typescript
orderCartItem = signal<OrderCartItem | null>(null);
```

Declare a signal that sets the product to be added, removed, or updated in the shopping cart.

```typescript
promoCode = signal<string>('');
```

Declare a signal that sets the promotional code of the shopping cart.

```typescript
private state = rxState<CartStoreState>(({ set, connect, select }) => {
    // set initial statement
    set(initialState);
    connect('cart', this.orderCartItem, (state, value) => {
      if (!value) {
        return state.cart;
      }

      const isRemove = value.action === 'remove' || value.action === 'update' && value.quantity <= 0;
      if (value.action === 'buy') {
        return buy(state, value);
      } else if (value.action === 'update' && value.quantity > 0) {
        return state.cart.map((item) => 
          item.id === value.id ? { ...item, quantity: value.quantity } : item);
      } else if (isRemove) {
        return state.cart.filter((item) => item.id !== value.id);
      }

      return state.cart;
    });
    connect('promoCode', this.promoCode)
});
```

`state` is a rxState that sets the initial states and connects the above signals to the state. 

```typescript
connect('cart', this.orderCartItem, (state, value) => {
      if (!value) {
        return state.cart;
      }

      const isRemove = value.action === 'remove' || value.action === 'update' && value.quantity <= 0;
      if (value.action === 'buy') {
        return buy(state, value);
      } else if (value.action === 'update' && value.quantity > 0) {
        return state.cart.map((item) => 
          item.id === value.id ? { ...item, quantity: value.quantity } : item);
      } else if (isRemove) {
        return state.cart.filter((item) => item.id !== value.id);
      }

      return state.cart;
    });
```

When the facade updates the `orderCartItem` signal, the function builds a new shopping cart and overwrites the state's cart property.

```typescript
connect('promoCode', this.promoCode)
```

When the facade updates the `promoCode` signal, the new signal value overwrites the state's promoCode property.

```typescript
cart = this.state.signal('cart');
```

Extract the cart from the state using the key 'cart'. 

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

    return 0;
  }) as Signal<number>;
```

`discountPercent` is a computed signal that derives the discount percentage based on the promotional code.

```typescript
summary = this.state.computed(({ cart }) => {
    const results = 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),
    }
  });
```

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

###Define a Cart Facade

```typescript
// cart.facade.ts

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

  cart = this.cartStore.cart;
  discountPercent = this.cartStore.discountPercent;
  summary = this.cartStore.summary;
  promoCode = this.cartStore.promoCode;

  updatePromoCode(promoCode: string) {
    this.cartStore.promoCode.set(promoCode);
  }

  addCart(idx: number, product: Product, quantity: number) {
    this.cartStore.orderCartItem.set({
      action: 'buy', idx, product, quantity,
    });
  }

  deleteCart(id: number) {
    this.cartStore.orderCartItem.set({ action: 'remove', id });
  }

  updateCart(id: number, quantity: number) {
    this.cartStore.orderCartItem.set({ action: '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 chose rx-angular/state in this demo but can easily replace it with TanStack Store, NgRx Signal Store or Angular Signal. The facade executes inject(CartStore) to inject an instance of CartStore, exposes read-only and computed signals, and defines methods that set the writeable signals directly.

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

```typescript
// 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.

```typescript
// 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.

```typescript
// 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.

```typescript
// 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 rx-angular/state library 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:

{%embed https://stackblitz.com/edit/stackblitz-starters-4pqo7a?file=src%2Fcarts%2Fstores%2Fcart.store.ts %}

This concludes my blog post about using rx-angular/state libraryl 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:

- Stackblitz Demo: https://stackblitz.com/edit/stackblitz-starters-4pqo7a?file=src%2Fcarts%2Fstores%2Fcart.store.ts
- Github Repo: https://github.com/railsstudent/ng-state-management-showcase/tree/main/projects/ng-rx-angular-demo
- Documentation: https://www.rx-angular.io/docs/state