State Management in Angular using Tanstack Store
Tags: angular, tutorial, statemanagement, webdev
Published: 2024-04-14 04:02:54

##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 the Angular Signal API to manage states.

In this blog post,  I want to use the TanStack 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

```bash
npm install @tanstack/angular-store @tanstack/store
```

###Create a cart state

```typescript
// product.interface.ts

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

```typescript
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;
  discountPercent: number;
  cart: CartItem[],
}
```

`CartStoreState` manages the shopping cart's state, consisting of a promotional code, discount, and items in the cart. Tanstack Store uses this interface to maintain and display the values in different cart components.

###Create a cart store

```typescript
// cart.store.ts

import { Store } from '@tanstack/store';
import { CartStoreState } from '../states/cart-store.state';

export const cartStore = new Store<CartStoreState>({
  promoCode: '',
  discountPercent: 0,
  cart: [],
});
```

Create a `cartStore` store that initializes a blank promotional code, 0 discount, and an empty cart. 

###Define a Cart Facade 

```typescript
// cart.facade.ts

@Injectable({
  providedIn: 'root'
})
export class CartFacade {
  private _cart = injectStore(cartStore, (state) => state.cart);
  private _discountPercent = injectStore(cartStore, (state) => state.discountPercent);
  private _summary = injectStore(cartStore, (state) => {
    const results = state.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 * state.discountPercent;
    const total = subtotal - discount; 

    return { 
      quantity, 
      subtotal: subtotal.toFixed(2),
      discount: discount.toFixed(2),
      total: total.toFixed(2),
    };
  });
  private _promoCode = injectStore(cartStore, (state) => state.promoCode);

  get cart() {
    return this._cart;
  }

  get discountPercent() {
    return this._discountPercent;
  }

  get summary() {
    return this._summary;
  }

  get promoCode() {
    return this._promoCode;
  }

  private getDiscount(code: string) {
    if (code === 'DEVFESTHK2023') {
      return 0.1;
    } else if (code === 'ANGULARNATION') {
      return 0.2;
    }

    return 0;
  } 

  updatePromoCode(promoCode: string) {
    const discountPercent = this.getDiscount(promoCode);

    cartStore.setState((state) => {
      return {
        ...state,
        promoCode,
        discountPercent,
      }
    });
  }

  addCart(idx: number, product: Product, quantity: number) {
    cartStore.setState((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 {
        ...state,
        cart: newCart,
      }
    });
  }

  deleteCart(id: number) {
    cartStore.setState((state) => {
      const updatedCart = state.cart.filter((item) => item.id !== id);

      return {
        ...state,
        cart: updatedCart,
      }
    });
  }

  updateCart(id: number, quantity: number) {
    if (quantity <= 0) {
      this.deleteCart(id);
    } else {
      cartStore.setState((state) => {
        const updatedCart = state.cart.map((item) => 
          item.id === id ? { ...item, quantity} : item 
        );

        return {
          ...state,
          cart: updatedCart,
        };
      });
    }
  }
}
```

`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 the TanStack Store in this demo, but I can replace it with an NGRX signal store or an Angular Signal API. The facade uses `InjectStore` to extract the cart, promoCode and percentDiscount properties from the store, all of which are read-only signals. It also uses `InjectStore` to derive `_summary` that holds an object of the quantity, the amount of discount, the subtotal, and the total. 

```typescript
 private _summary = injectStore(cartStore, (state) => {
    const results = state.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 * state.discountPercent;
    const total = subtotal - discount; 

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

The facade exposes the `addCart`, `deleteCart`, and `updateCart` methods to allow users to update the store. cartStore.setState method accepts a callback that constructs and returns a new state.

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(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);
  }
}
```

`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(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);
  }
}
```

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

```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);
    console.log('addItem', idx);
    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 application adds the new product to the cart when it does not exist.

The demo has successfully used the Tanstack Store to manage the state of the shopping cart.  When components want to access the store, they access it through the cart facade. 

The following Stackblitz Demo shows the final result: 

{%embed https://stackblitz.com/edit/stackblitz-starters-ssqczm?file=src%2Fcarts%2Ffarcades%2Fcart.farcade.ts %}

This concludes my blog post about using Angular and state management libraries such as TanStack 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:

- Stackblitz Demo: https://stackblitz.com/edit/stackblitz-starters-ssqczm?file=src%2Fcarts%2Ffarcades%2Fcart.farcade.ts
- Github Repo: https://github.com/railsstudent/ng-state-management-showcase/tree/main/projects/ng-tanstack-store-demo
- Documentation: https://tanstack.com/store/latest/docs/reference/Store