A Deep Dive into Angular and Ngneat Query with Our Demo Store

Connie Leung - Jan 11 - - Dev Community

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

npm install --save-exact @ngneat/query
npm install --save-exact --save-dev @ngneat/query-devtools
Enter fullscreen mode Exit fullscreen mode

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

Enable DevTools

// 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,
        },
      },
    }),
  ]
};
Enter fullscreen mode Exit fullscreen mode
//  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));
Enter fullscreen mode Exit fullscreen mode

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.

defaultOptions: {
      queries: {
          staleTime: Infinity,
      },
},
Enter fullscreen mode Exit fullscreen mode

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

Define Angular Queries in Category Service

// 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}`)
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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

getCategories() {
    return this.query({
      queryKey: ['categories'] as const,
      queryFn: () => this.httpClient.get<string[]>(CATEGORIES_URL)
    })
}
Enter fullscreen mode Exit fullscreen mode
  • 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

getCategory(category: string) {
    return this.query({
      queryKey: ['categories', category] as const,
      queryFn: () => this.httpClient.get<Product[]>(`${CATEGORY_URL}/${category}`)
    })
}
Enter fullscreen mode Exit fullscreen mode
  • queryKey is an array of ['categories', ]
  • queryFn is a query function that retrieves products that belong to a category

Define Angular Queries in Product Service

// 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;
          }),
        ),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Retrieve all products

getProducts() {
    return this.query({
      queryKey: ['products'] as const,
      queryFn: () => this.httpClient.get<Product[]>(PRODUCTS_URL)
    })
  }
Enter fullscreen mode Exit fullscreen mode
  • 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

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)
      })
    );
  }
Enter fullscreen mode Exit fullscreen mode
  • queryKey is an array of ['products', ]
  • 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.

{
  "ids": [
    4,
    19
  ]
}
Enter fullscreen mode Exit fullscreen mode
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;
          }),
        ),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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

// 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;
}
Enter fullscreen mode Exit fullscreen mode

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

@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>
}
Enter fullscreen mode Exit fullscreen mode

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

// 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$;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

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

// 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$;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

@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>
        }
      }
 }
Enter fullscreen mode Exit fullscreen mode

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

// 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[] }[])
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

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:

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .