How Angular is seriously delivering on DevEx (Part 2)

In our previous post, we explored how Angular has made significant strides in improving developer experience (DevEx) through the introduction of new features and improvements. New features include the introduction of Angular Signals, standalone components (and new APIs to help scaffold them), and the integration of esbuild and Vite into the Angular CLI. This has made Angular feel more modern and competitive with other frameworks like React and Vue by reducing mental overhead and improving build performance.

The Angular team has continued to deliver. We now see the introduction of even more powerful tools designed to streamline and enhance the way we build web applications. In this second and final part of our series, we'll take a look at a couple of recent standout features of Angular: deferrable views, and new and improved control flow syntax. These additions promise to further reduce complexity, improve performance, and offer developers more flexibility and control over their applications. Let's jump right in and begin to take a look at these new features.

Deferrable Views: Enhancing Performance and Efficiency

Deferrable views in Angular provide a powerful mechanism to defer the loading of template dependencies, including components, directives, pipes, and associated CSS. The deferrable view APIs come equipped with handlers for prefetching dependencies, triggers to control the loading process, and sub-blocks to manage specific deferred states in the UI. This approach not only simplifies the code but also enhances the performance of Angular applications by reducing initial load times.

Traditional Method of Handling Deferred Loading in Angular

Traditionally, deferred loading in Angular is handled using ViewContainerRef. This method, while effective, often involves complex management of cleanups, loading states, and error states. Developers need to manually manage the lifecycle of components, which can lead to increased complexity and potential for errors.

For example, say that you have a simple component that is called DeferredComponent that you want to defer loading until a specific event occurs:

// src/app/deferred/deferred.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-deferred',
  template: '<p>Deferred Component Loaded!</p>',
  styles: []
})
export class DeferredComponent {}

You can use ViewContainerRef to dynamically load the DeferredComponent on a button click event:

// src/app/app.component.ts
import { Component, ComponentFactoryResolver, ViewChild, ViewContainerRef, inject } from '@angular/core';
import { DeferredComponent } from './deferred/deferred.component';

@Component({
  selector: 'app-root',
  template: `
    <h1>Deferred Loading Example</h1>
    <button (click)="loadDeferredComponent()">Load Deferred Component</button>
    <ng-template #deferredContainer></ng-template>
  `,
  styles: []
})
export class AppComponent {
  @ViewChild('deferredContainer', { read: ViewContainerRef, static: true }) 
  container: ViewContainerRef;

  cfr = inject(ViewContainerRef);

  async loadDeferredComponent() {
    const { DeferredComponent } = await import('./deferred/deferred.component');
    const factory = this.cfr.resolveComponentFactory(DeferredComponent);
    this.container.createComponent(factory);
  }
}

While this method works, it can be cumbersome and error-prone, especially when dealing with multiple deferred components or complex loading scenarios. Imagine the scenario where you want to handle the loading state of this component. Our AppComponent would need to be updated to handle this:

// src/app/app.component.ts
import { Component, ComponentFactoryResolver, ViewChild, ViewContainerRef, inject } from '@angular/core';
import { DeferredComponent } from './deferred/deferred.component';

@Component({
  selector: 'app-root',
  template: `
    <h1>Deferred Loading Example</h1>
    <button (click)="loadDeferredComponent()">Load Deferred Component</button>
    <ng-template #deferredContainer></ng-template>
    <p *ngIf="isLoading">Loading...</p>
  `,
  styles: []
})
export class AppComponent {
  @ViewChild('deferredContainer', { read: ViewContainerRef, static: true }) 
  container: ViewContainerRef;
  
  isLoading = false;

  cfr = inject(ViewContainerRef);

  async loadDeferredComponent() {
    this.isLoading = true;
    try {
      const { DeferredComponent } = await import('./deferred/deferred.component');
      const factory = this.cfr.resolveComponentFactory(DeferredComponent);
      this.container.createComponent(factory);
    } catch (error) {
      console.error('Error loading component:', error);
    } finally {
      this.isLoading = false;
    }
  }
}

As you can see, we are beginning to layer on complexity to handle the loading state of our deferred component. This complexity can grow as we want to handle additional scenarios such as placeholder states, error states, and more.

This is where the new deferrable view features come in to simplify and streamline the process.

Deferrable Views in Angular

Deferrable views in Angular provide a more straightforward and efficient way to handle deferred loading of template dependencies including components, directives, pipes and CSS. By using the @defer template syntax, you can easily tell Angular to defer the loading of components until they are needed. This approach simplifies the code and reduces the complexity of managing deferred states.

Let's take a look at a quick example of how you can use deferrable views in Angular. Say we have a card component called DeferredCardComponent. The card creates an array of 100k randomly generated strings in it's state which is computationally expensive to render.

// src/app/deferred-card/deferred-card.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-deferred-card',
  standalone: true,
  imports: [],
  templateUrl: './deferred-card.component.html',
  styleUrl: './deferred-card.component.css',
})
export class DeferredCardComponent {
  public randomListOfText: string[] = [];

  constructor() {
    this.randomListOfText = this.generateRandomListOfText(100000);
  }

  private generateRandomListOfText(length: number): string[] {
    const randomListOfText: string[] = [];
    for (let i = 0; i < length; i++) {
      randomListOfText.push(this.getRandomText());
    }
    return randomListOfText;
  }

  private getRandomText(): string {
    return 'Random text ' + Math.floor(Math.random() * 60000);
  }
}
<!-- src/app/deferred-card/deferred-card.component.html -->
 <p class="text-bold">{{randomListOfText.length}} items loaded 😤</p>

Now that we have our DeferredCardComponent, we can use the @defer syntax to tell Angular to defer the loading of this component until it is needed. We'll create another component called AppComponent that will use the DeferredCardComponent and defer it's loading until a a UI element is hovered using the @defer syntax.

<!-- src/app/app.component.html -->
 <div class="flex justify-center">
  <div class="overflow-hidden bg-white shadow sm:rounded-lg w-80">
    <div class="px-4 py-5 sm:p-6">
    @defer (on hover) {
      <app-deferred-card></app-deferred-card>
    } @loading {
      <!-- loading skeleton -->
      <div class="h-6 bg-slate-200 rounded"></div>
    } @placeholder {
      <h2 class="font-bold text-lg">Hover me to start loading</h2>
    }
    </div>
  </div>
</div>

Our template handles the deferred loading of the DeferredCardComponent by using the @defer syntax in just a few simple blocks of code. The @defer syntax tells Angular to defer the loading of the DeferredCardComponent until the user hovers over the UI element. The @loading block displays a loading skeleton while the component is being loaded, and the @placeholder block displays a placeholder message until the component is loaded.

Here's a live demo of the DeferredCardComponent in action:

An Angular component that uses deferred loading to improve performance.

Loading...

When to Use Deferrable Views

Deferrable views are particularly useful in scenarios where components can significantly impact performance, such as:

  • Large lists that require substantial rendering resources
  • Components that make expensive network requests or involve complex computations

By deferring the loading of these components, you can improve the overall responsiveness and performance of your application.

Why Use Deferrable Views

Implementing deferrable views offers several benefits:

  • Reduce Initial Bundle Size: By deferring non-essential components, you can decrease the initial download size of your application, leading to faster load times.
  • Improve Core Web Vitals: Better performance and quicker load times contribute to improved Core Web Vitals, which are essential metrics for user experience and search engine rankings.

Deferrable views represent a significant enhancement in how Angular applications manage performance and efficiency. By adopting this approach, developers can create more responsive, user-friendly applications that load faster and perform better.

Control Flow

Angular 17 introduces new and improved control flow syntax that simplifies the way developers write and manage conditional logic in their applications. The new syntax provides a more intuitive and readable way to handle common control flow scenarios, such as if-else, for, and switch statements.

Handling control flow with legacy directives

The current control flow syntax in Angular involves using structural directives like *ngIf, *ngFor, and *ngSwitchCase to manage conditional logic in templates. While these directives are effective, they can sometimes lead to verbose and less readable code, especially when dealing with complex control flow scenarios.

For example, say we are building a todo list application where we want to display a list of tasks, the number of tasks if there are any, and a message if there are no tasks. Lastly, we want to display the priority of each task based on its value. Here's how we might handle this using the current control flow syntax:

We'd likely use *ngFor to iterate over the list of tasks and display each task's name.

<ul>
  <li *ngFor="let todo of todos; trackBy: trackById">
    {{ todo.name }}
  </li>
</ul>

Notice that we also included a trackBy function to help Angular track the identity of each task in the list. This is a best practice to ensure that Angular can identify each object being rendered in the loop and track changes efficiently.

trackById(index: number, todo: Todo): number {
  return todo.id;
}

In order to display the number of tasks of the list, we'd use an *ngIf directive to check if the list is empty or not.

 <div *ngIf="todos.length > 0">
  You have {{ todos.length }} tasks.
</div>
<div *ngIf="todos.length === 0">
  No tasks found.
</div>

Lastly, we'd use *ngSwitchCase to handle the priority of each task and display the corresponding message.

<div [ngSwitch]="todo.priority">
  <div *ngSwitchCase="'high'">High Priority</div>
  <div *ngSwitchCase="'medium'">Medium Priority</div>
  <div *ngSwitchCase="'low'">Low Priority</div>
  <div *ngSwitchDefault>Unspecified Priority</div>
</div>

There's definitely some things that could be improved here. Namely:

  • The verbosity of the syntax adds to cognitive overhead and can make the code harder to read and maintain.
  • The *ngFor directive requires a trackBy function to help Angular track changes efficiently.
  • The *ngIf directive is used to handle both the case where there are tasks and when there are none, which can lead to repetitive code.
  • The *ngSwitchCase does not provide any template type narrowing which seems like a missed opportunity.

New Control Flow Syntax

Let's revisit the same scenario using the new control flow syntax introduced in Angular. The new syntax provides a more concise and readable way to handle control flow scenarios, making it easier for developers to write and manage conditional logic in their templates.

Here's how we'd iterate over the list of tasks using the new control flow syntax:

<ul>
  @for (todo of todos; track todo.id) {
    <li>{{ todo.name }}</li>
  } @empty {
    <li>No tasks found.</li>
  }
</ul>

The @for block syntax replaces the *ngFor directive and provides a more intuitive way to iterate over a list of tasks. We no longer need to specify a trackBy function, and instead we can pass an expression to track the identity of each task. There's also a dedicated @empty block that handles the case where the list is empty.

Next, let's display the number of tasks using the new control flow syntax:

@if (todos.length > 0) {
  You have {{ todos.length }} tasks.
} @else {
  No tasks found.
}

The @if block syntax replaces the *ngIf directive and provides a more concise way to handle conditional logic. We can use the @else block to handle the case where there are no tasks in the list.

Finally, let's handle the priority of each task using the new control flow syntax:

@switch (todo.priority) {
  @case ('high') {
    High Priority
  }
  @case ('medium') {
    Medium Priority
  }
  @case ('low') {
    Low Priority
  }
  @default {
    Unspecified Priority
  }
}

The @switch block syntax replaces the *ngSwitchCase directive. The syntax is very reminiscent of the switch statement in JavaScript and provides a more intuitive way to handle multiple cases. We can use @case blocks to handle different priority levels and a @default block to handle the default case.

The @switch block also provides template type narrowing, which helps improve type safety and makes it easier to work with different cases.

When to Use the New Control Flow Syntax

The new control flow syntax is available for use in Angular 17 and greater, and can be used in any scenario where you would reach for the traditional control flow directives like *ngIf, *ngFor, and *ngSwitchCase.

Why Use the New Control Flow Syntax

The new control flow syntax offers several benefits:

  • Simplified Syntax: The new syntax provides a more concise and readable way to handle conditional logic in templates, reducing cognitive overhead and making the code easier to read and maintain.

  • Improved Type Safety: The new syntax provides template type narrowing, which helps improve type safety and makes it easier to work with different cases.

  • Better Performance: The new syntax is a concept that exists primarily at compile time. It is "disapearring" syntax that is removed from the final output. This means that the new syntax has no runtime impact on the performance of your application.

Conclusion

Recent updates to Angular introduce powerful new features that further enhance the developer experience and streamline the way we build web applications. Deferrable views provide a more efficient way to handle deferred loading of template dependencies, while the new control flow syntax simplifies the way we write and manage conditional logic in our applications. By adopting these new features, developers can create more performant, responsive, and user-friendly applications that load faster and perform better.


References

Introducing Angular v17 - Angular Blog
Deferrable Views - Angular Docs
Built-in control flow - Angular Docs