Angular Service: State Management

communication is all about sharing and we can use services to share entity data across components. entity data can be for example posts, products, songs, movies and so on. this kind of service is a data access service.

We use the service as a data access service to encapsulate the common operations with data like get, add, update and delete data items. in the service we create methods that usually will make HTTP requests to get, add, update and delete data. each component that needs the data will use the service to get the data and will make an action on the data through the use of the service.

when the components get their own data without a service:

  • They don”t take advantage of the data that already retrieved
  • Navigating away and back to the component, the component will have to retrieve the data again

Using the service as a data service solves the issues above. but keep in mind that this technique is not useful when the application must retrieve the freshest data like a reservation system or stock market application. but if the data doesn’t change that often this technique is great.

The purpose of a state management service is to:

  • Provide state values
  • Maintain and update state
  • Observe state changes

To turn a basic data access service into a state management service we need to:

  • Add a property to retain the list of entities (this property is populated the first time we retrieve the data)
  • On get we return the list of entities (if its already been retrieved)
  • On get by ID we return the item from the existing list (since we use the retained list as the source of entity data, we will need to keep this list updated as the user create, update or delete items)
  • optionally we can add code to track the time and expired the retained list after predefined amount of time

In our example we will create a service that will retrieve the products once when the component first requests the data and then we will share that data with any component that needs it.

for that we will create a property names products (we can think of that property as a data cache)  that will keep the data we retrieve and it will be shared across components that request this data.

When the component will request the data the service will return them the data from the cached property and when the components request a single entity from the data, the service will return them the single data from the cached property.

Another property that we will create in the service is currentProduct , it will keep the state for the selected item. To keep track of the changes for this property, we will bound it to template of the component that use it with a getter. (change detection reevaluate the binding and calls the getter which re gets the value appropriately).

Any time the user will select a different item, we will change the property inside the service, and the component that needs it will get the updated selected item with the getter.

In the example below I use data from a file instead of making an HTTP request for simplicity


import { Injectable } from "@angular/core";
import {
  HttpClient,
  HttpErrorResponse,
  HttpHeaders
} from "@angular/common/http";

import { catchError, tap } from "rxjs/operators";
import { Observable, of, throwError } from "rxjs";
import { IProduct } from "./product";

@Injectable({
  providedIn: "root"
})
export class ProductService {
  private productsUrl = "assets/products.json";
  private products: IProduct[];
  currentProduct: IProduct | null;

  constructor(private http: HttpClient) {}

  getProducts(): Observable<IProduct[]> {
    if (this.products) {
      return of(this.products);
    }
    return this.http.get<IProduct[]>(this.productsUrl).pipe(
      tap(data => console.log(JSON.stringify(data))),
      tap(data => (this.products = data)),
      catchError(this.handleError)
    );
  }

  getProduct(id: number): Observable<IProduct> {
    if (id === 0) {
      return of(this.initializeProduct());
    }
    if (this.products) {
      const foundItem = this.products.find(item => item.id === id);
      if (foundItem) {
        return of(foundItem);
      }
    }
    const url = `${this.productsUrl}/${id}`;
    return this.http.get<IProduct>(url).pipe(
      tap(data => console.log("Data: " + JSON.stringify(data))),
      catchError(this.handleError)
    );
  }

  saveProduct(product: IProduct): Observable<IProduct> {
    const headers = new HttpHeaders({ "Content-Type": "application/json" });
    if (product.id === 0) {
      return this.createProduct(product, headers);
    }
    return this.updateProduct(product, headers);
  }

  deleteProduct(id: number): Observable<IProduct> {
    const headers = new HttpHeaders({ "Content-Type": "application/json" });
    const url = `${this.productsUrl}/${id}`;
    return this.http.delete<IProduct>(url, { headers: headers }).pipe(
      tap(data => console.log("deleteProduct: " + id)),
      tap(data => {
        const foundIndex = this.products.findIndex(item => item.id === id);
        if (foundIndex > -1) {
          this.products.splice(foundIndex, 1);
          this.currentProduct = null;
        }
      }),
      catchError(this.handleError)
    );
  }

  private createProduct(
    product: IProduct,
    headers: HttpHeaders
  ): Observable<IProduct> {
    product.id = null;
    return this.http
      .post<IProduct>(this.productsUrl, product, { headers: headers })
      .pipe(
        tap(data => console.log("createProduct: " + JSON.stringify(data))),
        tap(data => {
          this.products.push(data);
          this.currentProduct = data;
        }),
        catchError(this.handleError)
      );
  }

  private updateProduct(
    product: IProduct,
    headers: HttpHeaders
  ): Observable<IProduct> {
    const url = `${this.productsUrl}/${product.id}`;
    return this.http.put<IProduct>(url, product, { headers: headers }).pipe(
      tap(data => console.log("updateProduct: " + product.id)),
      catchError(this.handleError)
    );
  }

  private initializeProduct(): IProduct {
    // Return an initialized object
    return {
      id: 0,
      productName: "",
      productCode: "",
      category: "",
      tags: [],
      releaseDate: "",
      price: 0,
      description: "",
      starRating: 0,
      imageUrl: ""
    };
  }

  private handleError(err: HttpErrorResponse) {
    // in a real world app, we may send the server to some remote logging infrastructure
    // instead of just logging it to the console
    let errorMessage: string;
    if (err.error instanceof Error) {
      // A client-side or network error occurred. Handle it accordingly.
      errorMessage = `An error occurred: ${err.error.message}`;
    } else {
      // The backend returned an unsuccessful response code.
      // The response body may contain clues as to what went wrong,
      errorMessage = `Backend returned code ${err.status}, body was: ${
        err.error
      }`;
    }
    console.error(err);
    return throwError(errorMessage);
  }
}



/* Retrieving the list of products */
import { Component, OnInit } from "@angular/core";
import { IProduct } from "./product";

import { ProductService } from "./product.service";

@Component({
  selector: "child1",
  template: `
    <div *ngFor="let product of products">
      <div (click)="onProductSelection(product)" class="product">
        {{ product.productName }}:
        {{ product.price | currency: "USD":"symbol" }}
      </div>
    </div>
  `,
  styles: [
    `
      .product {
        cursor: pointer;
      }
    `
  ]
})
export class Child1Component implements OnInit {
  products: IProduct[];
  errorMessage: string;

  constructor(private productService: ProductService) {}

  ngOnInit(): void {
    this.productService.getProducts().subscribe(
      (products: IProduct[]) => {
        this.products = products;
      },
      (error: any) => (this.errorMessage = <any>error)
    );
  }

  onProductSelection(product: IProduct): void {
    this.productService.currentProduct = product;
  }
}



/* Showing a single item  */
import { Component } from "@angular/core";
import { IProduct } from "./product";
import { ProductService } from "./product.service";

@Component({
  selector: "child2",
  template: `
    <div class="card">
      <button (click)="getProduct(2)">get Item number 2</button>
      <br />
      <div *ngIf="product">
        {{ product?.productName }}:
        {{ product?.price | currency: "USD":"symbol" }}
      </div>
    </div>
  `,
  styles: [
    `
      .card {
        border: 1px solid #000;
        padding: 20px;
      }
    `
  ]
})
export class Child2Component {
  product: IProduct;
  errorMessage: string;

  constructor(private productService: ProductService) {}

  getProduct(id: number) {
    this.productService
      .getProduct(id)
      .subscribe(
        product => (this.product = product),
        error => (this.errorMessage = <any>error)
      );
  }
}




/* Showing selected item */
import { Component } from "@angular/core";
import { IProduct } from "./product";
import { ProductService } from "./product.service";

@Component({
  selector: "child3",
  template: `
    <div class="card">
      <div *ngIf="product">
        {{ product?.productName }}:
        {{ product?.price | currency: "USD":"symbol" }}
      </div>
      <div *ngIf="!product">
        None item selected
      </div>
    </div>
  `,
  styles: [
    `
      .card {
        border: 1px solid #000;
        padding: 20px;
      }
    `
  ]
})
export class Child3Component {
  get product(): IProduct | null {
    console.log("sadada", this.productService.currentProduct);
    return this.productService.currentProduct;
  }

  constructor(private productService: ProductService) {}
}


Click here for live demo