Introduction
In this blog post, I would like to deep dive into Angular and Tanstack query by calling a Store API to build a store demo. Tanstack 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 Tanstack 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
Install ngneat 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,
},
},
}),
]
};
// 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 Ngneat query via provideQueryClientOptions
.
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
// 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
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
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
// 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
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
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.
{
"ids": [
4,
19
]
}
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
// 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
.
@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
// 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 Ngneat 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>
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$;
}
}
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>
}
}
}
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[] }[])
);
}
ProductListComponent
groups products by categories and displays. In this component, I use intersectResults
utility function that Ngneat 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>
When categoryProducts().isLoading
is true, the query is waiting for the computation to complete. Therefore, the template displays a loading text. When
is true, the new query encounters an error and the template displays an error message. When categoryProducts()
.isErrorcategoryProducts().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 Tanstack 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:
- 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
- Angular TanQuery Repo: https://github.com/ngneat/query/