Simplify Routing Parameters in Angular Components

From ActivatedRoute to withComponentInputBinding in Angular Router

ยท

4 min read

In my previous article on building a sample store, I explored the animation feature in Angular 17. At the same time, I also developed an extensive product page feature.

The product detail page needs to extract the product ID from the URL using the router and then use this ID to fetch product information from another API, similar to the following:

To achieve this, I need to configure the ActivatedRoute in conjunction with the RouterLink. Let's get started.

Create your component product-detail using the angular CLI:

ng g c pages/product-detail

In my app.routes.ts, I added the new path using :id to represent the parameter and the loadComponent.

  {
    path:'products/:id',
    loadComponent: () =>
        import('./pages/product-detail/product-detail.component').then(c => c.ProductDetailComponent)
  }

In my code, I added the routerLink directive to direct users to the product details page, incorporating both the URL and the parameter.

[routerLink]="['/products/', product.id]

In my template, it appears as follows:

@for (product of products();track product.id) {
  <kendo-card width="360px" [routerLink]="['/products/', product.id]"
              style="cursor: pointer">
    <img [src]="product.image" kendoCardMedia alt="cover_img"/>
    <kendo-card-body>
      <h4>{{ product.title }}</h4>
    </kendo-card-body>
    <kendo-card-footer class="k-hstack">
      <span>Price {{ product.price | currency }}</span>
    </kendo-card-footer>
  </kendo-card>
} @empty {
  <h2> No products! ๐Ÿ˜Œ</h2>
}

Read The Router Parameter

In the details page, we need to inject both ActivatedRoute and HttpClient. In my case, I make the request within the same component.

Using route.paraMap with combination of switchMap, obtain the parameter and make the request to return the product. To skip the subscription or use the pipe async, I use the toSignal, and convert the observable from httpClient to a signal, which can then be used in the template.

To keep this article concise, the request is made within the same component, although using an external service is recommended.


  route = inject(ActivatedRoute)
  http = inject(HttpClient);

  $product = toSignal<Product>(this.route.paramMap.pipe(
    switchMap(params => {
      const id = params.get('id');
      return this.http.get<Product>(`https://fakestoreapi.com/products/${id}`)
    })
  ));

In the template, we read the signal by using () to gain access to the returned product.

@if ($product()) {
    <kendo-card width="360px" >
      <img [src]="$product()?.image" kendoCardMedia alt="cover_img"/>
      <kendo-card-body>
        <h4>{{ $product()?.title }}</h4>
        <hr kendoCardSeparator/>
        <p>
          {{ $product()?.description }}
        </p>
      </kendo-card-body>
      <kendo-card-footer class="k-hstack">
        <span>Price {{ $product()?.price | currency }}</span>
      </kendo-card-footer>
    </kendo-card>
  } @else {
  <h2> Sorry product not found ๐Ÿ˜Œ</h2>
}

Save, and everything should work! But is there a better or more concise way to do it? Since Angular 16.1, we can use the bind Input property that matches with the parameter. Let's give it a try!

Using Input with Router Parameter.

First we must to provide withComponentInputBinding() function into the router config. it enable to bind the input that match with the route parameter, in our case is id .

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes,  withComponentInputBinding()),
    provideHttpClient()
  ]
};

In the product-details, add the id property with the input decorator, and then use the id to make the request. To ensure the id is set, print it in the constructor.

export class ProductDetailComponent {
  #http = inject(HttpClient);
  @Input() id!: string;
  $product = toSignal<Product>(this.#http.get<Product>(`https://fakestoreapi.com/products/${this.id}`));

  constructor() {
    console.log(this.id);
  }

The id is undefined, which causes the product to be requested with an undefined value, because the id has not yet been set in the component. To access it, let's try reading it in the OnInit lifecycle.

Indeed, it received the id, but the toSignal() function needs to run within the injectionContext(). If you've read my previous article on Understanding InjectionContext, you'll recognize that we're facing a similar situation.

To resolve this issue, we need to inject the Injector and impor trunInInjectionContext.

  #http = inject(HttpClient);
  #injector = inject(Injector);

To make the code cleaner, move the request to a new function called getProductDetail.

private getProductDetail() {
    runInInjectionContext(this.#injector, () => {
      this.$product = toSignal(
        this.#http.get<Product>(`https://fakestoreapi.com/products/${this.id}`),
      );
    });
  }

The final code will look like this:

export class ProductDetailComponent implements OnInit {
  @Input() id!: string;
  $product!: Signal<Product | undefined>;
  #http = inject(HttpClient);
  #injector = inject(Injector);

  ngOnInit(): void {
    this.getProductDetail();
  }

  private getProductDetail() {
    runInInjectionContext(this.#injector, () => {
      this.$product = toSignal(
        this.#http.get<Product>(`https://fakestoreapi.com/products/${this.id}`),
      );
    });
  }
}

Save the changes, and everything should work! Yay!!!

Recap

The feature withComponentInputBinding helps us avoid subscriptions and inject the route and get the values from route easy. But of course if the process the data is bit complex, I highly recommend move the logic to a service.

I recommend checkout the official documentation

Happy Coding ๐Ÿ˜

ย