Migrating Angular v17+ to signal inputs, signal outputs, control flow and more

Migrating Angular v17+ to signal inputs, signal outputs, control flow and more

Posted on
Last update on

TL;DR

In case you just need the commands, here is the overview. Check below for more details. If you are using Nx, you can replace ng with nx.

Control flow migration command

npx ng g @angular/core:control-flow
npx ng g @angular/core:control-flow --path=libs/feature-xyz
npx ng add ngxtension

Signal inputs migration command

npx ng g ngxtension:convert-signal-inputs
npx ng g ngxtension:convert-signal-inputs --path=libs/feature-xyz/my-component.component.ts
npx ng g ngxtension:convert-signal-inputs --project=feature-xyz

Signal outputs migration command

npx ng g ngxtension:convert-outputs
npx ng g ngxtension:convert-outputs --path=libs/feature-xyz/my-component.component.ts
npx ng g ngxtension:convert-outputs --project=feature-xyz

Signal queries migration command

npx ng g ngxtension:convert-queries
npx ng g ngxtension:convert-queries --path=libs/feature-xyz/my-component.component.ts
npx ng g ngxtension:convert-queries --project=feature-xyz

Inject function migration command (ngxtension)

npx ng g ngxtension:convert-di-to-inject
npx ng g ngxtension:convert-di-to-inject --path==libs/feature-xyz/my-component.component.ts
npx ng g ngxtension:convert-di-to-inject --project==feature-xyz

Inject function migration command (Angular)

npx ng generate @angular/core:inject-migration
npx ng generate @angular/core:inject-migration --path==libs/feature-xyz

Target audience

This article is intended to help anybody willing to migrate their Angular v17+ project to use signal inputs, signal outputs, the new control flow syntax and inject function.

It does not focus on the specifics of these concepts, just how you can migrate to them. If you want to learn more about them, please check the "Further reading" section at the bottom of this page.

Introduction

We start with a very simple example of a project that uses the well known @Input() annotation for component inputs and @Output() for component outputs. Besides that we have a service that is injected using constructor arguments and we are using the typical *ngIf, *ngFor and *ngSwitch structural directives to control the data rendering in our components.

With running a few commands we will be able to automagically migrate these "old" mechanisms to the new input(), output(), inject() and control flow blocks using @if, @for and @switch.

Example Setup

Our example project can be found on Github. It is a very minimal counter Angular project that has 2 inputs, 2 outputs, some state service that holds a writable signal with the current count. Based on the current count we show some information and derived values.

The fully migrated project can be found on a separate branch called migrate-to-angular-v17-plus. See my Github repository and/or the diff with the main branch for more details.

Migration commands

For updating each of the concepts we have separate commands that will migrate our code automatically. But before we continue, we need to install a new dependency called ngxtension. This very neat library of migration schematics and other helper utilities will help us migrate to the new Angular v17+ standards.

npx ng add ngxtension

Let's get started!

Control flow syntax

In the HTML of our components we use structural directives such as *ngIf, *ngFor and *ngSwitch, but before they can be used, they need to be imported first. Since standalone components, we can import them separately in the component's or through the CommonModule.

Component HTML code

<ng-container *ngIf="count === 0; else notZero">
  The count is zero.. :(
</ng-container>
<ng-template #notZero>
  The count is not zero!
</ng-template>
  ...
<ng-container [ngSwitch]="count">
  <div *ngSwitchCase="0">There are 0 numbers</div>
  <div *ngSwitchCase="1">There is only 1 number:{{ count }}</div>
  <div *ngSwitchCase="-1">There is only 1 number:{{ count }};</div>
  <div *ngSwitchDefault>
    There are multiple numbers:
    <ul>
      <li *ngFor="let i of numbers">{{ i }}</li>
    </ul>
  </div>
</ng-container>

Component TypeScript code

@Component ({
  standalone: true,
  imports: [NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault],
  // imports: [CommonModule],
  selector: 'app-current-count',
  templateUrl: './current-count.component.html',
  styleUrl: './current-count.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush
})

For this migration we don't need the @ngxextension library yet. To migrate to the new control flow syntax, we need to run the following migration command provided by the Angular CLI. If we want to migrate just a part of our codebase, we can specify the path to limit the migration to:

Control flow migration command

npx ng g @angular/core:control-flow
npx ng g @angular/core:control-flow --path=libs/feature-xyz

After running this command the imports for the structural directives will be removed as this functionality is available through the compiler directly. Our HTML code from above will be transformed to:

Migrated HTML code

@if (count === 0) {
  The count is zero.. :(
} @else {
  The count is not zero!
}
...
@switch (count) {
  @case (0) {
    <div>There are 0 numbers</div>
  }
  @case (1) {
    <div>There is only 1 number: {{ count }}</div>
  }
  @case (-1) {
    <div>There is only 1 number: {{ count }}</code></div>
  }
  @default {
    <div>
      There are multiple numbers
      <ul>
        @for (i of numbers; track i) {
          <li>{{ i }}</li>
        }
      </ul>
    </div>
  }
}

Extra trick: @empty block

With the new control flow syntax, we can use the @empty block to handle the case where the list of items to iterate over is empty. This means no more checking for an empty list with an *ngIf or even @if condition check first:

@empty block example

<section id="blogposts">
  <h1>Blogposts</h1>
  @for (blogpost of blogposts; track blogposts.id) {
    <article>
      <h2>{{ blogpost.title }}</h2>
      <p>{{ blogpost.introduction }}</p>
    </article>
  } @empty {
    <p>No blogposts available</p>
  }
</section>

Annotated @Input to signal input

Migrating to the new signal inputs is easy with the help of the ngxtension library. We can run the following command to migrate our @Input() annotated properties to the new signal input syntax. Same as with the control flow migration, we can specify a path to limit the migration to component or directive file in our project. Alternatively, we can target a specific project as well.

Signal inputs migration command

npx ng g ngxtension:convert-signal-inputs
npx ng g ngxtension:convert-signal-inputs --path=libs/feature-xyz/my-component.component.ts
npx ng g ngxtension:convert-signal-inputs --project=feature-xyz

This results in the code transforming from:

Annotated @Input

import { Input } from '@angular/core';
...
export class CurrentCountComponent {
  @Input() count: number = 0;
  @Input() numbers: number[] = [];
}

To the new signal input syntax:

Signal inputs

import { input } from '@angular/core';
...
export class CurrentCountComponent {
  count = input(0);
  numbers = input([]);
}

Annotated @Output to signal output

Similar steps and options apply to the migration of annotated @Outputs to signal based outputs.

Signal outputs migration command

npx ng g ngxtension:convert-outputs
npx ng g ngxtension:convert-outputs --path=libs/feature-xyz/my-component.component.ts
npx ng g ngxtension:convert-outputs --project=feature-xyz

This results in the code transforming from:

Annotated @Output

import { Output } from '@angular/core';
...
export class OperationsComponent {
  @Output() increment = new EventEmitter();
  @Output() decrement = new EventEmitter();
}

To the new signal based outputs. Note that both input and output are exported from the same package, namely @angular/core, with just a subtle difference; lower input/output instead of Input/Output:

Signal outputs

import { output } from '@angular/core';
...
export class OperationsComponent {
  increment = output();
  decrement = output();
}

Annotated Queries

To make sure we have a uniform experience with signals, starting from Angular v17, we can also migrate to signal based queries using the ngxtension tooling library.

Signal queries migration command

npx ng g ngxtension:convert-queries
npx ng g ngxtension:convert-queries --path=libs/feature-xyz/my-component.component.ts
npx ng g ngxtension:convert-queries --project=feature-xyz

The following code, querying a list of images in our component, will be transformed from:

Signal outputs

export class AppComponent {
  @ViewChildren('my-images') myImages: QueryList>;
}

into:

Signal outputs

export class AppComponent {
  myImages = viewChildren>('my-images');
}

Read more in the documentation of @ngxtension for examples and more.

Constructor injection to inject function

While this is not a v17+ specific feature, but available from v14, it is worth highlighting. In Angular you can make use of the TypeScript constructor to inject services into your components. The compiler will then apply it's magic to run the dependency injection mechanism to make sure you get the right instance of the provided arguments.

The syntax in the code snippet below makes the stateService publicly available on the instance of the app component class.

Component TypeScript code

export class AppComponent {
  constructor(public readonly stateService: StateService) { }
}

The only requirement here is that the class that you are injecting has the @Injectable annotation or is provided manually. To run the migration we can again leverage the ngxtension library:

Inject function migration command (ngxtension)

npx ng g ngxtension:convert-di-to-inject
npx ng g ngxtension:convert-di-to-inject --path==libs/feature-xyz/my-component.component.ts
npx ng g ngxtension:convert-di-to-inject --project==feature-xyz

Running the command above will result in the following code transformation:

Component TypeScript code

export class AppComponent {
  public readonly stateService = inject(StateService);
}

Update (September 2024): Because of popular demand, the Angular team created a native migration for the inject function.

Inject function migration command (Angular)

npx ng generate @angular/core:inject-migration
npx ng generate @angular/core:inject-migration --path==libs/feature-xyz

Conclusion

With these simple commands provided by the Angular team and the maintainers of ngxtension we can easily migrate our Angular projects to keep them up-to-date and be aligned with the new standards.

Further reading

  1. Meet Angular’s New Control Flow
  2. Introducing Angular v17
  3. inject() Migration (Angular native)
  4. inject() Migration (ngxtension)
  5. New output() Migration
  6. Signal Inputs Migration
  7. Queries Migration

Contents

  1. Target audience
  2. Introduction
  3. Example Setup
  4. Migration commands
  5. Control flow syntax
  6. Extra trick: @empty block
  7. Annotated @Input to signal input
  8. Annotated @Output to signal output
  9. Annotated Queries
  10. Constructor injection to inject function
  11. Conclusion
  12. Further reading

By reading this article I hope you can find a solution for your problem. If it still seems a little bit unclear, you can hire me for helping you solve your specific problem or use case. Sometimes even just a quick code review or second opinion can make a great difference.