Introduction
In this blog post, I demonstrate the technique of using data resolver function to retrieve data during route change. When route finishes activation, component has access to the route data and can render it in template or to manipulate it to derive new states.
I was working on a fake store demo where the first page displays all the products in a list. When user clicks a product name, user is routed to the product details page. This use case is very straightforward but I actually spent some time to implement the final solution.
The initial solution was to make a HTTP request to retrieve an Observable of product, and then use NgIf
and AsyncPipe
to resolve the Observable in the template. Angular introduced Signal
and I thought to store the product in a Signal and render the template with Signal value. The solution was not elegant and I had to scratch it. Finally, I implemented a data resolver function to retrieve a product by id. When the product details page is routed completely, I extracted the product from the route data and used it within the component.
Use case of the demo
In the fake store demo, I call the product API to retrieve all the products and render them in ProductListComponent. When user clicks the name of the product, I call another API to retrieve the details by id and display the data on ProductDetailsComponent.
// routes.ts
export const routes: Routes = [
{
path: 'products',
loadComponent: () => import('./products/product-list/product-list.component').then((m) => m.ProductListComponent),
title: 'Product list',
},
{
path: 'products/:id',
loadComponent: () => import('./products/product-details/product-details.component').then((m) => m.ProductDetailsComponent),
title: 'Product',
},
];
This use case is typical of a CRUD application but the solution that obtains the product actually required 3 iterations to develop.
Attempt 1: Retrieving an Observable of product by id, and resolving the Observable in the HTML template using NgIf and AsyncPipe.
Attempt 2: Applying toSignal to convert Observable to Signal and display the Signal value in the template. This solution was actually over complicated and it made thing worse
Attempt 3: Using data resolver function to retrieve an Observable or product. With the help of withComponentInputBinding, the product is available in ProductDetailsComponent as an input. Then, I applied the input in the inline template to display the product values
In the next few sections, I am going to show how I iteratively implemented the resolve function to return the product and to use it in the component.
Solution 1: Getting an Observable from API and resolving it in the inline template
With the help of withComponentInputBinding
, the path param (id) is an input of ProductDetailsComponent
. I used the id to retrieve the product, and resolved the Observable in the inline template using NgIf and AsyncPipe.
// main.ts
bootstrapApplication(App, {
providers: [provideHttpClient(), provideRouter(routes, withComponentInputBinding())]
});
// product.service.ts
const PRODUCTS_URL = 'https://fakestoreapi.com/products';
@Injectable({
providedIn: 'root'
})
export class ProductService {
private readonly httpClient = inject(HttpClient);
getProduct(id: number): Observable<Product | undefined> {
return this.httpClient.get<Product>(`${PRODUCTS_URL}/${id}`)
.pipe(catchError((err) => of(undefined)));
}
}
// product-details.component.ts
@Component({
selector: 'app-product-details',
standalone: true,
imports: [AsyncPipe, NgIf],
template: `
<div>
<div class="product" *ngIf="product$ | async as product">
<div class="row">
<img [src]="product?.image" [attr.alt]="product?.title" width="200" height="200" />
</div>
<div class="row">
<span>id:</span><span>{{ id }}</span>
</div>
<div class="row">
<span>Category: </span><span>{{ (product?.category '' }}</span>
</div>
<div class="row">
<span>Description: </span><span>{{ product?.description || '' }}</span>
</div>
<div class="row">
<span>Price: </span><span>{{ product?.price || '' }}</span>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductDetailsComponent implements OnInit {
@Input({ required: true, transform: numberAttribute })
id!: number;
productService = inject(ProductService);
product$!: Observable<Product | undefined>;
ngOnInit() {
this.product$ = this.productService.getProduct(this.id);
}
}
However, I preferred not to deal with Observable
, NgIf
and AsyncPipe
in the component and the HTML template. If I don't use Observable, I will use Signal that tracks reactivity in an application.
Let me refactor the solution to use Signal and display the Signal value in the inline template.
Solution 2: Converting Observable to Signal and display Signal value in the inline template
toSignal()
is a function that converts Observable
to Signal
and I thought it is the only thing I needed in ngOnInit
. However, the compiler issued an error because toSignal()
was not called in an injection context. To fix this error, I performed the logic in the callback function of runInInjectionContext
.
// product-details.component.ts
injector = inject(Injector);
product: Signal<Product | undefined> = signal(undefined);
ngOnInit() {
runInInjectionContext(this.injector, () => {
this.product = toSignal(this.productService.getProduct(this.id),
{ initialValue: undefined });
});
}
With Signal, I don't have to import NgIf
and AsyncPipe
, and every occurrence of product
changes to product()
.
<div>
<div class="product">
<div class="row">
<img [src]="product()?.image" [attr.alt]="product()?.title" width="200" height="200" />
</div>
<div class="row">
<span>id:</span><span>{{ id }}</span>
</div>
<div class="row">
<span>Category: </span><span>{{ product()?.category || '' }}</span>
</div>
<div class="row">
<span>Description: </span><span>{{ product()?.description || '' }}</span>
</div>
<div class="row">
<span>Price: </span><span>{{ product()?.price || '' }}</span>
</div>
</div>
</div>
When comparing between the Observable and the Signal solutions, the latter one added unnecessary complexities such as inject(Injector)
and runInInjectionContext(...)
. I would rather revert to the original solution than to use toSignal
for the sakes of using Signal
.
Then, I formulated another solution that is using data resolver function to obtain the product during route change. Similarly, withComponentInputBinding
should give me a product input in ProductDetailsComponent.
Solution 3: Using data resolver function to return the product to ProductDetailsComponent
The data resolver function accepts a route and returns either an Observable or a Promise. Therefore, I extracted the id from the URL and passed it to the Product Service to retrieve the Observable of product.
// product.resolver.ts
export const productResolver = (route: ActivatedRouteSnapshot) => {
const productId = route.paramMap.get('id');
if (!productId) {
return of(undefined);
}
return inject(ProductService).getProduct(+productId);
}
In route.ts
, I modified the routes configuration to assign productResolver to the resolve property of products/:id
path.
// route.ts
export const routes: Routes = [
{
path: 'products/:id',
loadComponent: () => import('./products/product-details/product-details.component').then((m) => m.ProductDetailsComponent),
title: 'Product',
resolve: {
product: productResolver,
}
},
];
Next, I could clean up ProductDetailsComponents
because the product resolver eliminated the previous logic in ngOnInit
.
// product-details.component
@Component({
selector: 'app-product-details',
standalone: true,
template: `
<div>
<div class="product">
<div class="row">
<img [src]="product?.image" [attr.alt]="product?.title || 'product image'"
width="200" height="200"
/>
</div>
<div class="row">
<span>id:</span>
<span>{{ product?.id || '' }}</span>
</div>
<div class="row">
<span>Category: </span>
<span>{{ product?.category || '' }}</span>
</div>
<div class="row">
<span>Description: </span>
<span>{{ product?.description || '' }}</span>
</div>
<div class="row">
<span>Price: </span>
<span>{{ product?.price || '' }}</span>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductDetailsComponent {
@Input()
product: Product | undefined = undefined;
}
Finally, I replaced all occurrences of product()
with product
in the inline template.
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.
Top comments (4)
I think resolvers are, in real world cases, unusable to say the least. I've explained here why: stackoverflow.com/questions/490542... and in several sub comments that people have made.
For me, using effects for example with ngrx makes a lot more sense and offers way more possibilities. Because you can manage streams of events compared to individual ones. For example, if you want to load some profile data, you want to load when the user goes on the page, and when the user clicks on a reload button. You can manage all those from the same effect and use an exshautMap or a concatMap instead of firing potentially multiple times the same request. It also doesn't block the routing if you've got a slow response time and let you display a spinner for example to keep the attention of the user.
Thank you for the feedback.
My thought process was not to use Observable and Signal did not improve my solution. Then, I resorted to resolver that cleaned up the component (no Observable, no AsyncPipe and no Signal). However, I forgot the route will be blocked and user sees nothing when network request is slow.
I will take this into mind. However, Access to route data is simplified when used in conjunction with combineWithInputBinding that I like a lot secretly.
Small tip: if you export your route component as "default", you can simplify route definition:
No need for
.then((m) => m.ProductDetailsComponent)
.Thanks for the tip. I can try it in the next demo.