Experience the magic of the new control flow in Angular 17

Connie Leung - Dec 8 '23 - - Dev Community

Introduction

In this blog post, I demonstrated the migration of Angular 16 structure directives (NgIf and NgSwitch) to Angular 17 new control flow (@if/@else and @for/track) in a online shopping cart demo.

During the migration exercise, I revised the inline templates to replace structure directives with the new control flow, deleted trackby function in Component classes and removed redundant import of NgFor and NgIf.

In the following sections, I am going to show the before code blocks that are written in structure directives and the after code blocks that apply the new control flow

Use case of the demo

In the fake store demo, I have a main page that displays 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.

In the shopping cart, it has a table that renders cart item rows. The cart item row has buttons to delete the entire row, increment or decrement quantity. Moreover, there is an input field to input promotional code to obtain a discount for the entire cart. In the summary section of the cart, it displays subtotal, discount (optional) and the total amount of the purchase.

This demo has various product and cart components, and some of them display data in the template conditionally or repeatedly. These components are good choices to apply the new control flow instead of the structure directives.

Product use cases

Before and after of ProductDetailsComponent

Angular V16 (Structural Directives version)

// product-details.component.ts

import { NgIf, TitleCasePipe } from '@angular/common';

@Component({
  selector: 'app-product-details',
  standalone: true,
  imports: [TitleCasePipe, FormsModule, NgIf],
  template: `
    <div>
      <ng-container *ngIf="product else noProduct">
        ... product html codes...
      </ng-container>
    </div>
    <ng-template #noProduct>
      <p>Product is invalid</p>
    </ng-template>
  `, 
})
export class ProductDetailsComponent {  
  @Input()
  product: Product | undefined = undefined; 
}
Enter fullscreen mode Exit fullscreen mode

In V16, the imports array includes NgIf and the inline template has a ng-container with ngIf and ngElse directives. The ngElse is followed with a noProduct NgTemplate:

<ng-template #noProduct>
      <p>Product is invalid</p>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

Angular V17 (New control flow version)

// product-details.component.ts

import { TitleCasePipe } from '@angular/common';

@Component({
  selector: 'app-product-details',
  standalone: true,
  imports: [TitleCasePipe, FormsModule],
  template: `
    <div>
      @if (product) {
            ... ... product html codes...
      } @else {
        <p>Product is invalid</p>
      }
    </div>
  `,
})
export class ProductDetailsComponent {
  @Input()
  product: Product | undefined = undefined; 
}
Enter fullscreen mode Exit fullscreen mode

In V17, there are three changes in this component. First, the imports array does not include NgIf. Second, I got rid of ng-container and added @if block to display the product HTML code. Third, there is no NgTemplate and the @else block displays the paragraph element that was in the NgTemplate.

Before and after of ProductListComponent

Angular V16 (Structural Directives version)

// product-list.component.ts

import { NgFor } from '@angular/common';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [NgFor, ProductComponent],
  template: `
    <div>
      <app-product *ngFor="let product of products(); trackBy: trackByFunc" [product]="product" />
    </div>
  `,
})
export class ProductListComponent {
  products = toSignal(inject(ProductService).products$, {
    initialValue: [] as Product[]
  });

  trackByFunc(index: number, product: Product) {
    return product.id;
  }
}
Enter fullscreen mode Exit fullscreen mode

In V16, the imports array includes NgFor and the inline template has a ngFor directive that iterates products signal to pass product to the input of ProductComponent. Moreover, trackBy invokes trackByFunc function to track the list items by product id.

trackByFunc(index: number, product: Product) {
    return product.id;
}
Enter fullscreen mode Exit fullscreen mode

trackByFunc is optional in ngFor but it can make the list with good performance when it has many items.

Angular V17 (New control flow version)

// product-list.component.ts

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [ProductComponent],
  template: `
    <div>
      @for (product of products(); track product.id) {
        <app-product [product]="product" />
      }
    </div>
  `,
})
export class ProductListComponent {
  products = toSignal(inject(ProductService).products$, {
    initialValue: [] as Product[]
  });
}
Enter fullscreen mode Exit fullscreen mode

In V17, there are three changes in this component. First, the imports array does not include NgFor. Second, trackBy function is replaced by track that accepts an expression. The expression can be $index system, an Object or an unique id. Third, I got rid of trackByFunc from the component class because @for block uses @track to track the list items by product id.

Cart Use Cases

Before and after of CartComponent

Angular V16 (Structural Directives version)

// cart.component.ts

import { NgFor, NgIf } from '@angular/common';

@Component({
  selector: 'app-cart',
  standalone: true,
  imports: [NgFor, CartItemComponent, CartTotalComponent, NgIf, FormsModule],
  template: `
    <div class="cart">
      <ng-container *ngIf="cart().length > 0 else emptyCart">
         ... omit HTML codes brevity ...

        <app-cart-item *ngFor="let item of cart(); trackBy: trackByFunc" [item]="item" />

        ... omit HTML codes for brevity ...
      </ng-container>
    </div>
    <ng-template #emptyCart>
      <p>Your cart is empty, please buy something.</p>
    </ng-template>
  `,
})
export class CartComponent {
  cartService = inject(CartService);
  cart = this.cartService.cart;
  promoCode = this.cartService.promoCode;

  trackByFunc(index: number, item: CartItem) {
    return item.id;
  }
}
Enter fullscreen mode Exit fullscreen mode

In V16, the imports array includes NgIf and NgFor, and the inline template has a ng-container with ngIf and ngElse directives. The ngElse is followed with an emptyCart NgTemplate:

<ng-template #emptyCart>
      <p>Your cart is empty, please buy something.</p>
</ng-template>
Enter fullscreen mode Exit fullscreen mode
<app-cart-item *ngFor="let item of cart(); trackBy: trackByFunc" [item]="item" />
Enter fullscreen mode Exit fullscreen mode

ngFor directive iterates the cart signal to pass cart item to the input of CartItemComponent. Moreover, trackBy invokes trackByFunc function to track the list items by product id.

Angular V17 (New control flow version)

// cart.component.ts

@Component({
  selector: 'app-cart',
  standalone: true,
  imports: [CartItemComponent, CartTotalComponent, FormsModule],
  template: `
    @if (cart().length > 0) {
      <div class="cart">
        ... omit HTML codes for brevity ...

        @for (item of cart(); track item.id) {
          <app-cart-item [item]="item" />
        }

         ... omit HTML codes for brevity ...
      </div>
    } @else {
      <p>Your cart is empty, please buy something.</p>
    }
  `,
})
export class CartComponent {
  cartService = inject(CartService);
  cart = this.cartService.cart;
  promoCode = this.cartService.promoCode;
}
Enter fullscreen mode Exit fullscreen mode

In V17, there are four changes in this component. First, the imports array does not include NgIf and NgFor. Second, I got rid of ng-container and added @if block to display the cart item HTML code. Third, there is no NgTemplate and the @else block displays the paragraph element with the text "Your cart is empty, please buy something.". Last, @for block iterates the cart signal to render CartItemComponent and the mandatory @track block tracks list items by product id.

Before and after of CartTotalComponent

Angular V16 (Structural Directives version)

// cart-total.component.ts

import { NgIf, PercentPipe } from '@angular/common';

@Component({
  selector: 'app-cart-total',
  standalone: true,
  imports: [PercentPipe, NgIf],
  template: `
    <div class="summary">
      ... omit HTML codes for brevity...

      <div class="row" *ngIf="discountPercent() > 0">
        <div class="col">Minus {{ discountPercent() | percent:'2.2-2' }}</div> 
        <div class="col">Discount: {{ summary().discount }}</div>
      </div>

       ... omit HTML codes for brevity ....
    </div>
  `,
})
export class CartTotalComponent {
  cartService = inject(CartService);
  summary = this.cartService.summary;
  discountPercent = this.cartService.discountPercent;
}
Enter fullscreen mode Exit fullscreen mode

In V16, the imports array includes NgIf, and the inline template has a ngIf directive that displays the percentage of discount and the amount of discount conditionally.

Angular V17 (New control flow version)

// cart-total.component.ts

import { PercentPipe } from '@angular/common';

@Component({
  selector: 'app-cart-total',
  standalone: true,
  imports: [PercentPipe],
  template: `
    <div class="summary">
      ... omit HTML codes for brevity ...

      @if (discountPercent() > 0) {
        <div class="row">
          <div class="col">Minus {{ discountPercent() | percent:'2.2-2' }}</div> 
          <div class="col">Discount: {{ summary().discount }}</div>
        </div>
      }

      ... omit HTML codes for brevity....
    </div>
  `,
})
export class CartTotalComponent {
  cartService = inject(CartService);
  summary = this.cartService.summary;
  discountPercent = this.cartService.discountPercent;
}
Enter fullscreen mode Exit fullscreen mode

In V17, there are four changes in this component. First, the imports array does not include NgIf. Second, I added @if block to display the HTML code of discount percent and total discount conditionally.

Navigation Bar Use Case

Before and after of NavBarComponent

Angular V16 (Structural Directives version)

// nav-bar.component.ts

import { AsyncPipe, NgIf } from '@angular/common';
import { isCurrentUrlIncludedFn } from './utilities/is-current-url-included';

@Component({
  selector: 'app-nav-bar',
  standalone: true,
  imports: [RouterLink, NgIf, AsyncPipe],
  template: `
    <div>
      <a *ngIf="(isShowHomeButton$ | async) else spacer" routerLink="/">Home</a>
      <a [routerLink]="['my-cart']">View Cart</a>
    </div>
    <ng-template #spacer>
      <span>&nbsp;</span>
    </ng-template>
  `,
})
export class NavBarComponent {
  cdr = inject(ChangeDetectorRef);
  isShowHomeButton$ = isCurrentUrlIncludedFn('/', '/products')
    .pipe(tap(() => this.cdr.markForCheck()));
}
Enter fullscreen mode Exit fullscreen mode

In V16, the imports array includes NgIf, and the inline template has a anchor element with ngIf and ngElse directives. The ngElse is followed with a spacer NgTemplate:

<ng-template #spacer>
      <span>&nbsp;</span>
</ng-template>>
Enter fullscreen mode Exit fullscreen mode

Angular V17 (New control flow version)

// nav-bar.component.ts

import { AsyncPipe } from '@angular/common';
import { isCurrentUrlIncludedFn } from './utilities/is-current-url-included';

@Component({
  selector: 'app-nav-bar',
  standalone: true,
  imports: [RouterLink, AsyncPipe],
  template: `
    <div>
      @if (isShowHomeButton$ | async) {
        <a routerLink="/">Home</a>
      } @else {
        <span>&nbsp;</span>
      }
      <a [routerLink]="['my-cart']">View Cart</a>
    </div>
  `,
})
export class NavBarComponent {
  cdr = inject(ChangeDetectorRef);
  isShowHomeButton$ = isCurrentUrlIncludedFn('/', '/products')
    .pipe(tap(() => this.cdr.markForCheck()));
}
Enter fullscreen mode Exit fullscreen mode

In V17, there are three changes in this component. First, the imports array does not include NgIf. Second, I added @if block to display an anchor element that routes user to home. Third, there is no NgTemplate and the @else block displays the &nbsp character.

At this point, the new control flow replaces all occurrences of structure directives in the 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:

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