DEV Community

Connie Leung
Connie Leung

Posted on • Updated on

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

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:

Top comments (9)

Collapse
 
hakimio profile image
Tomas Rimkus

It would be interesting to see some examples of how to handle paging, sorting, filtering and selecting properties/columns with @ngneat/query with client side caching of data pages.
Or even better: how to create service for pivot data grid with server side data aggregation.

Collapse
 
railsstudent profile image
Connie Leung

Excellent ideas. the full capability of the library has not maximized yet.

Collapse
 
ducin profile image
Tomasz Ducin

Hi @railsstudent! I'm afraid you confused Tanstack Angular Query with ngneat Query. Although these are similar, they are separate libraries - the title suggest X but it's about Y 😛

Collapse
 
railsstudent profile image
Connie Leung • Edited

When I visited the github page of ngneat query, the description is "The TanStack Query (also known as react-query) adapter for Angular applications". There are 2 implementations, who can tell me their names to differentiate them?

Collapse
 
ducin profile image
Tomasz Ducin

Ngneat query, as it's outside and not an official port - and (tanstack) angular query - as it's within the stack AND is the official one.

Thread Thread
 
ducin profile image
Tomasz Ducin

Ngneat is not even mentioned in tanstack query v5 docs.

Thread Thread
 
railsstudent profile image
Connie Leung

It was mentioned in v4 docs. Then, I found out Angular v5 was written by a different developer. It is so confusing for me.

Collapse
 
jangelodev profile image
João Angelo

Excellent article!
Thanks for sharing, Connie Leung !

Collapse
 
railsstudent profile image
Connie Leung

You are welcome.