Retrieve route data with resolver function in Angular

Connie Leung - Nov 5 '23 - - Dev Community

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',
  },
];
Enter fullscreen mode Exit fullscreen mode

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())]
});
Enter fullscreen mode Exit fullscreen mode
// 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)));
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 });
    });
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
  },
];
Enter fullscreen mode Exit fullscreen mode

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; 
}
Enter fullscreen mode Exit fullscreen mode

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.

Resources:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .