angularangular-routerangular-httpclientangular-signalsangular19

How can I bind an id from the URL to an Angular signal and use that signal to fetch product details from an API?


Goal I'm using Angular with the new signals feature and the experimental HttpResource. I want to:

Extract an id parameter from the route. Bind this id to a signal. Use that signal to fetch product details from an API whenever the value changes.

What I Tried
export class ProductDetailComponent implements OnInit {
  private _route = inject(ActivatedRoute);

  readonly productId = computed(() => {
    return Number(this._route.snapshot.paramMap.get('productId'));
  });

  readonly productResource = computed(() => {
    const id = this.productId();

    if (!id) return null;

    return httpResource<any>({
      url: `product/${id}`,
      method: 'GET'
    });
  });

  readonly product = computed(() => this.productResource()?.value());
}

What I Expected
Read the productId from the URL using Angular Router.
Track it with a computed signal.
Fetch product details automatically when the productId is available or changes.

Solution

  • We can use toSignal to convert any observable to a signal, we then use map to transform the string productId to a number.

    readonly productId = toSignal<number>( 
      this._route.params.pipe(map((res: Params) => +res['productId']))
    );
    

    Once we have the productId signal, we can easily call the httpResource, this is an initializer API, so you can just initialize it once at the root of the class, no need to wrap it inside a computed or method.

    readonly productResource = httpResource<any>(
      () => `https://jsonplaceholder.typicode.com/todos/${this.productId()}`
    );
    

    The httpResource accepts a callback, since you are just making a GET call (default mode), just specify the url as the return value to the callback.

    The signals used inside the URL will be used to trigger the reactivity, when the signals change, the URL is fetched again.

    Then we use computed to get the value from the resource, which is overkill I think, but I guess it is there for a reason.

    readonly product = computed(() => this.productResource.value());
    

    Finally, we can use the methods error and isLoading to either show a error or loading message, if something went wrong, or the data is still loading.

    We finally use the computed to show the value.

    @if(productResource.error()) {
      Some error occourred.
    } @else if(productResource.isLoading()) {
      Loading...
    } @else {
      {{product() | json}}
    }
    

    Full Code:

    import { Component, inject, computed } from '@angular/core';
    import { toSignal } from '@angular/core/rxjs-interop';
    import { bootstrapApplication } from '@angular/platform-browser';
    import {
      ActivatedRoute,
      provideRouter,
      RouterLink,
      RouterOutlet,
      Params,
    } from '@angular/router';
    import { provideHttpClient, httpResource } from '@angular/common/http';
    import { JsonPipe } from '@angular/common';
    import { map } from 'rxjs';
    
    @Component({
      selector: 'app-child',
      imports: [JsonPipe],
      template: `
        @if(productResource.error()) {
          Some error occourred.
        } @else if(productResource.isLoading()) {
          Loading...
        } @else {
          {{product() | json}}
        }
      `,
    })
    export class Child {
      private _route = inject(ActivatedRoute);
      readonly productId = toSignal<number>(
          this._route.params.pipe(map((res: Params) => +res['productId']))
        );
    
        readonly productResource = httpResource<any>(
          () => `https://jsonplaceholder.typicode.com/todos/${this.productId()}`
        );
    
        readonly product = computed(() => this.productResource.value());
    }
    
    @Component({
      selector: 'app-root',
      imports: [RouterOutlet, RouterLink],
      template: `
        <a routerLink="/child/1">
          Child 1
        </a> | 
        <a routerLink="/child/2">
          Child 1
        </a> | 
        <a routerLink="/child/3">
          Child 1
        </a>
        <div>
          <router-outlet/>
        </div>
      `,
    })
    export class App {
      name = 'Angular';
    }
    
    bootstrapApplication(App, {
      providers: [
        provideRouter([{ path: 'child/:productId', component: Child }]),
        provideHttpClient(),
      ],
    });
    

    Stackblitz Demo