A Deep Dive into Angular and Ngneat Query with Our Demo Store
Tags: tutorial, typescript, angular, webdev
Published: 2024-01-11 07:09:07

##Introduction

In this blog post, I would like to deep dive into Angular and Ngneat query by calling a Store API to build a store demo. Ngneat query for Angular is a library for fetching, caching, sychronizing, and updating server data.  `ngneat/query` is an Angular Tanstack query adaptor that supports Signals and Observable, fetching, caching, sychronization and mutation.  

The demo tries to deep dive into Angular and Ngneat query by retrieving products and categories from server, and persisting the data to cache.  Then, the data is rendered in inline template using the new control flow syntax.

###Use case of the demo

In the store demo, I have a home page that displays featured products and a list of product cards.  When customer clicks the name of the card, the demo navigates the customer to the product details page.  In the product details page, customer can add product to shopping cart and click "View Cart" link to check the cart contents at any time.

I use Angular Tanstack query to call the server to retrieve products and categories. Then, the query is responsible for caching the data with a unique query key.  Angular Tankstack query supports Observable and Signal; therefore, I choose either one depending on the uses cases.  

###Install dependencies

```bash
npm install --save-exact @ngneat/query
npm install --save-exact --save-dev @ngneat/query-devtools
```

Install angular query from `@ngneat/query` and devtools from `@ngneat/query-devtools`

###Enable DevTools

```typescript
// app.config.ts

import { provideQueryDevTools } from '@ngneat/query-devtools';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideRouter(routes, withComponentInputBinding()),
    {
      provide: TitleStrategy,
      useClass: ProductPageTitleStrategy,
    },
    isDevMode() ? provideQueryDevTools({
      initialIsOpen: true
    }): [],
    provideQueryClientOptions({
      defaultOptions: {
        queries: {
          staleTime: Infinity,
        },
      },
    }),
  ]
};
```
```typescript
//  main.ts

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));
```

When the application is in development mode, it enables the devTools. Otherwise, it does not open the devTools in production mode. Moreover, I provide default options to Angular query via `provideQueryClientOptions`.

```typescript
defaultOptions: {
      queries: {
          staleTime: Infinity,
      },
},
```

All queries have infinite stale time such that they are not called to load fresh data.

###Define Angular Queries in Category Service

```typescript
// category.service.ts

import { injectQuery } from '@ngneat/query';

const CATEGORIES_URL = 'https://fakestoreapi.com/products/categories';
const CATEGORY_URL = 'https://fakestoreapi.com/products/category';

@Injectable({
  providedIn: 'root'
})
export class CategoryService {
  private readonly httpClient = inject(HttpClient);
  private readonly query = injectQuery();

  getCategories() {
    return this.query({
      queryKey: ['categories'] as const,
      queryFn: () => this.httpClient.get<string[]>(CATEGORIES_URL)
    })
  }

  getCategory(category: string) {
    return this.query({
      queryKey: ['categories', category] as const,
      queryFn: () => this.httpClient.get<Product[]>(`${CATEGORY_URL}/${category}`)
    })
  }
}
```

In `CategoryService`, I create an instance of `QueryClient` through `injectQuery()`.  Then, I use the this.query function to define few Angular Tanstack queries.  It accepts an object of queryKey and queryFn

- queryKey -  an array of values that look up  an object from the cache uniquely
- queryFn - a query function that returns an Observable

####Retrieve all categories

```typescript
getCategories() {
    return this.query({
      queryKey: ['categories'] as const,
      queryFn: () => this.httpClient.get<string[]>(CATEGORIES_URL)
    })
}
```

- queryKey is an array of constant value, ['categories']
- queryFn is a query function that retrieves all categories by  CATEGORIES_URL

####Retrieve products that belong to a category

```typescript
getCategory(category: string) {
    return this.query({
      queryKey: ['categories', category] as const,
      queryFn: () => this.httpClient.get<Product[]>(`${CATEGORY_URL}/${category}`)
    })
}
```

- queryKey is an array of ['categories',  <category string>]
- queryFn is a query function that retrieves products that belong to a category

###Define Angular Queries in Product Service

```typescript
// product.service.ts

import { injectQuery } from '@ngneat/query';

const PRODUCTS_URL = 'https://fakestoreapi.com/products';
const FEATURED_PRODUCTS_URL = 'https://gist.githubusercontent.com/railsstudent/ae150ae2b14abb207f131596e8b283c3/raw/131a6b3a51dfb4d848b75980bfe3443b1665704b/featured-products.json';

@Injectable({
  providedIn: 'root'
})
export class ProductService {
  private readonly httpClient = inject(HttpClient);
  private readonly query = injectQuery();

  getProducts() {
    return this.query({
      queryKey: ['products'] as const,
      queryFn: () => this.httpClient.get<Product[]>(PRODUCTS_URL)
    })
  }

  getProduct(id: number) {
    return this.query({
      queryKey: ['products', id] as const,
      queryFn: () => this.getProductQuery(id),
      staleTime: 2 * 60 * 1000,
    });
  }

  private getProductQuery(id: number) {
    return this.httpClient.get<Product>(`${PRODUCTS_URL}/${id}`).pipe(
      catchError((err) => {
        console.error(err);
        return of(undefined)
      })
    );
  }

  getFeaturedProducts() {
    return this.query({
      queryKey: ['feature_products'] as const,
      queryFn: () => this.httpClient.get<{ ids: number[] }>(FEATURED_PRODUCTS_URL)
        .pipe(
          map(({ ids }) => ids), 
          switchMap((ids) => {
            const observables$ = ids.map((id) => this.getProductQuery(id));
            return forkJoin(observables$);
          }),
          map((productOrUndefinedArrays) => {
            const products: Product[] = [];
            productOrUndefinedArrays.forEach((p) => {
              if (p) {
                products.push(p);
              }
            });
            return products;
          }),
        ),
    });
  }
}
```

####Retrieve all products

```typescript
getProducts() {
    return this.query({
      queryKey: ['products'] as const,
      queryFn: () => this.httpClient.get<Product[]>(PRODUCTS_URL)
    })
  }
```

- queryKey is an array of constant value, ['products']
- queryFn is a query function that retrieves all products by PRODUCTS_URL

####Retrieve a product by id

```typescript
getProduct(id: number) {
    return this.query({
      queryKey: ['products', id] as const,
      queryFn: () => this.getProductQuery(id),
      staleTime: 2 * 60 * 1000,
    });
  }

  private getProductQuery(id: number) {
    return this.httpClient.get<Product>(`${PRODUCTS_URL}/${id}`).pipe(
      catchError((err) => {
        console.error(err);
        return of(undefined)
      })
    );
  }
```

- queryKey is an array of ['products',  <product id>]
- queryFn is a query function that retrieves a product by product id. When getProductQuery returns an error, it is caught to return undefined 
- the stale time is 2 minutes; therefore,  this query re-fetches data when the specific product is older than 2 minutes in the cache

####Retrieve feature products

`FEATURED_PRODUCTS_URL` is a github gist that returns an array of ids.

```json
{
  "ids": [
    4,
    19
  ]
}
```

```typescript
getFeaturedProducts() {
    return this.query({
      queryKey: ['feature_products'] as const,
      queryFn: () => this.httpClient.get<{ ids: number[] }>(FEATURED_PRODUCTS_URL)
        .pipe(
          map(({ ids }) => ids), 
          switchMap((ids) => {
            const observables$ = ids.map((id) => this.getProductQuery(id));
            return forkJoin(observables$);
          }),
          map((productOrUndefinedArrays) => {
            const products: Product[] = [];
            productOrUndefinedArrays.forEach((p) => {
              if (p) {
                products.push(p);
              }
            });
            return products;
          }),
        ),
    });
  }
}
```

- queryKey is an array of ['feature_products']
- queryFn is a query function that retrieves an array of products by an array of product ids. The last map RxJS filters out undefined to return an array of Product

I define all the Angular queries for category and product, and the next step is to build components to display the query data 

###Design Feature Products Component

```typescript
// feature-products.component.ts

@Component({
  selector: 'app-feature-products',
  standalone: true,
  imports: [ProductComponent],
  template: `
    @if (featuredProducts().isLoading) {
      <p>Loading featured products...</p>
    } @else if (featuredProducts().isSuccess) {
      <h2>Featured Products</h2>
      @if (featuredProducts().data; as data) {
        <div class="featured">
          @for (product of data; track product.id) {
            <app-product [product]="product" class="item" />
          }
        </div>
      }
      <hr>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FeatureProductsComponent {
  private readonly productService = inject(ProductService);
  featuredProducts = this.productService.getFeaturedProducts().result;
}
```

`this.productService.getFeaturedProducts().result` returns a signal and assigns to `featuredProducts`. 

```typescript
@if (featuredProducts().isLoading) {
      <p>Loading featured products...</p>
 } @else if (featuredProducts().isSuccess) {
      <h2>Featured Products</h2>
      @if (featuredProducts().data; as data) {
        <div class="featured">
          @for (product of data; track product.id) {
            <app-product [product]="product" class="item" />
          }
        </div>
      }
      <hr>
}
```

When `isLoading` is true, the query is getting the data and the data is not ready. Therefore, the template displays a loading text. When `isSuccess` is true, the query retrieves the data successfully and `featuredProducts().data` returns a product array.  The  for/track block  iterates the array to pass each product to the input of `ProductComponent` to display product information.

###Design Product Details Component

```typescript
// product-details.component.ts

import { ObservableQueryResult } from '@ngneat/query';

@Component({
  selector: 'app-product-details',
  standalone: true,
  imports: [TitleCasePipe, FormsModule, AsyncPipe, RouterLink],
  template: `
    <div>
      @if(product$ | async; as product) {
        @if (product.isLoading) {
          <p>Loading...</p>
        } @else if (product.isError) {
          <p>Product is invalid</p>
        } @else if (product.isSuccess) {
          @if (product.data; as 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>
                <span>
                  <a [routerLink]="['/categories', data.category]">{{ data.category | titlecase }}</a>
                </span>
              </div>
              <div class="row">
                <span>Name: </span>
                <span>{{ data.title }}</span>
              </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>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductDetailsComponent implements OnInit {
  @Input({ required: true, transform: numberAttribute })
  id!: number;

  productService = inject(ProductService);
  product$!: ObservableQueryResult<Product | undefined>;
  
  ngOnInit(): void {
    this.product$ = this.productService.getProduct(this.id).result$;
  }
}
```

`id` is defined in `ngOnInit` and less code is written to obtain an Observable than a Signal. Therefore, I choose to use the Observable result of Angular query.  In `ngOnInit`, I invoke `this.productService.getProduct(this.id).result$` and assign the Product Observable to `this.product$`.

```typescript
<div>
      @if(product$ | async; as product) {
        @if (product.isLoading) {
          <p>Loading...</p>
        } @else if (product.isError) {
          <p>Product is invalid</p>
        } @else if (product.isSuccess) {
          @if (product.data; as data) {
            <div class="product">
              <div class="row">
                <span>id:</span>
                <span>{{ data.id }}</span>
              </div>
               /**  omit other rows **/
            </div>
          }
        }
      }
</div>
```

I import `AsyncPipe` in order to resolve `product$` to a `product` variable. When `product.isLoading` is true, the query is getting the product and it is not ready. Therefore, the template displays a loading text. When `product.isError` is true, the query cannot retrieve the details and the template displays an error message. When `product.isSuccess` is true, the query retrieves the product successfully and `product.data` is a JSON object.  It is a simple task to display the product fields in a list.  

###Design Category Products  Component

```typescript
// category-products.component.ts

import { ObservableQueryResult } from '@ngneat/query';

@Component({
  selector: 'app-category-products',
  standalone: true,
  imports: [AsyncPipe, ProductComponent, TitleCasePipe],
  template: `
    <h2>{{ category | titlecase }}</h2>      
    @if (products$ | async; as products) {
      @if(products.isLoading) {
        <p>Loading...</p>
      } @else if (products.isError) {
        <p>Error: {{ products.error.message }}</p>
      } @else if(products.isSuccess) {
        @if (products.data.length > 0) {
          <div class="products">
            @for(product of products.data; track product.id) {
              <app-product [product]="product" />
            }
          </div>
        } @else {
          <p>Category does not have products</p>
        }
      }
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CategoryProductsComponent implements OnInit {
  @Input({ required: true })
  category!: string;

  categoryService = inject(CategoryService);
  products$!: ObservableQueryResult<Product[], Error>;

  ngOnInit(): void {
    this.products$ = this.categoryService.getCategory(this.category).result$;
  }
}
```

Similarly, `CategoryProductsComponent` uses category string to retrieve all products that have the specified category.  The category input is available in `ngOnInit` method and `this.categoryService.getCategory(this.category).result$` assigns an Observable of product array to `this.products$`. 

```typescript
@if (products$ | async; as products) {
      @if(products.isLoading) {
        <p>Loading...</p>
      } @else if (products.isError) {
        <p>Error: {{ products.error.message }}</p>
      } @else if(products.isSuccess) {
        @if (products.data.length > 0) {
          <div class="products">
            @for(product of products.data; track product.id) {
              <app-product [product]="product" />
            }
          </div>
        } @else {
          <p>Category does not have products</p>
        }
      }
 }
```

I import `AsyncPipe` in order to resolve `products$` to a `products` variable. When `products.isLoading` is true, the query is getting the products and they are not ready. Therefore, the template displays a loading text. When `products.isError` is true, the query encounters an error and the template displays an error message. When `products.isSuccess` is true, the query retrieves the products successfully and `product.data.length` returns the number of products.  When there is more than 0 product, each product is passed to the input of `ProductComponent` to render. Otherwise, a simple message describes the category has no product.

###Design Product List Component

```typescript
// cart-total.component.ts

import { intersectResults } from '@ngneat/query';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [ProductComponent, TitleCasePipe],
  template: `
    <h2>Catalogue</h2>
    <div>
      @if (categoryProducts().isLoading) {
        <p>Loading...</p>
      } @else if (categoryProducts().isError) {
        <p>Error</p>
      } @else if (categoryProducts().isSuccess) { 
        @if (categoryProducts().data; as data) {
          @for (catProducts of data; track catProducts.category) {
            <h3>{{ catProducts.category | titlecase }}</h3>
            <div class="products">
            @for (product of catProducts.products; track product.id) {
              <app-product [product]="product" />
            }
            </div>
          }
        }
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductListComponent {
  productService = inject(ProductService);
  categoryService = inject(CategoryService);

  categoryProducts = intersectResults(
    { 
      categories: this.categoryService.getCategories().result, 
      products: this.productService.getProducts().result
    },
    ({ categories, products }) => 
      categories.reduce((acc, category) => {
        const matched = products.filter((p) => p.category === category);

        return acc.concat({
          category,
          products: matched,
        });
      }, [] as { category: string; products: Product[] }[])
  );
}
```

`ProductListComponent` groups products by categories and displays.  In this component, I use `intersectResults` utility function that Angular query offers.   `intersectResults` combines multiple Signals and/or Observable to create a new query.   In this use case,  I combine categories and products signals to group products by categories, and assign the results to `categoryProducts` signal.

```typescript
<div>
      @if (categoryProducts().isLoading) {
        <p>Loading...</p>
      } @else if (categoryProducts().isError) {
        <p>Error</p>
      } @else if (categoryProducts().isSuccess) { 
        @if (categoryProducts().data; as data) {
          @for (catProducts of data; track catProducts.category) {
            <h3>{{ catProducts.category | titlecase }}</h3>
            <div class="products">
            @for (product of catProducts.products; track product.id) {
              <app-product [product]="product" />
            }
            </div>
          }
        }
      }
</div>
```

When `categoryProducts().isLoading` is true, the query is waiting for the computation to complete. Therefore, the template displays a loading text. When `categoryProducts().isError` is true, the new query encounters an error and the template displays an error message. When `categoryProducts().isSuccess` is true, the query gets the new results back and `categoryProducts().data` returns the array of grouped products.  The `for/track` block iterates the array to pass the input to `ProductComponent` to render.

At this point, the components have successfully leveraged Angular queries to retrieve data and display it on browser. It is also the end of the deep dive of Angular and Ngneat query for the store demo.

The following Stackblitz repo shows the final results:
{% embed https://stackblitz.com/edit/stackblitz-starters-rxgmfk?file=src%2Fmain.ts %}

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

##Resources:
- Github Repo: https://github.com/railsstudent/ng-online-store-tanstack-query-demo
- Github Page: https://railsstudent.github.io/ng-online-store-tanstack-query-demo/products
- Stackblitz Demo: https://stackblitz.com/edit/stackblitz-starters-rxgmfk?file=src%2Fmain.ts
- Ngneat Query Repo:  https://github.com/ngneat/query/