Módulo 28: Migraciones y Angular clásico

Objetivos del módulo

  • Leer con soltura una aplicación clásica: NgModule, declarations, forRoot, forChild y standalone: false
  • Reconocer *ngIf, *ngFor, ngSwitch, ngClass y ngStyle, y entender en qué se convierten por dentro
  • Traducir mentalmente @Input, @Output, @ViewChild, @HostBinding y la inyección por constructor a sus equivalentes modernos
  • Entender qué hacía Zone.js y por qué código que funcionaba con él deja de pintar sin él
  • Saber qué hace ng update por dentro y preparar un proyecto para que salga bien
  • Actualizar de verdad un proyecto de Angular 21 a 22, leyendo lo que cambia cada migración
  • Arreglar a mano lo que las migraciones no tocan: API eliminadas y cambios sin aviso
  • Retirar una a una las protecciones que deja la actualización
  • Modernizar con ng generate @angular/core:...: flujo de control, standalone, inject, @Service y señales
  • Quitar Zone.js de una aplicación y comprobar que sigue funcionando

1. Por qué aprender lo antiguo al final del curso

Todo lo que has escrito hasta aquí usa Angular moderno: componentes standalone, señales, @if y @for, y sin Zone.js. Si solo fueras a crear proyectos nuevos, podrías saltarte este módulo. Pero en una empresa lo normal es lo contrario: te dan una aplicación con años de vida, con decenas de módulos y miles de líneas, y te piden añadirle una pantalla sin romper nada.

Ese código no está mal. Se escribió con lo que había, y sigue compilando en Angular 22: lo comprobaremos en este módulo con una aplicación clásica completa. Lo que necesitas no es escribirlo, sino leerlo y saber cómo llevarlo al presente sin riesgos.

Este módulo tiene tres partes:

  1. Leer (secciones 2 a 6): las piezas antiguas, con su equivalente moderno al lado.
  2. Actualizar (secciones 7 a 11): de la 21 a la 22 con ng update, sobre un proyecto real, mirando qué hace cada migración.
  3. Modernizar (secciones 12 a 14): pasar el código clásico a la sintaxis actual con los generadores de Angular, y quitar Zone.js.

Una regla que conviene grabarse desde el principio: actualizar y modernizar son dos trabajos distintos. Actualizar cambia la versión y procura que todo siga funcionando igual. Modernizar cambia cómo está escrito el código y es opcional. Mezclarlos en el mismo paso es el error más caro, porque si algo se rompe no sabrás cuál de los dos cambios ha sido.

Cómo datar un proyecto de un vistazo

Antes de tocar nada, unos pocos indicios te dicen ante qué tipo de código estás:

Si ves… Estás ante… Equivalente actual Sección
app.module.ts, @NgModule Arquitectura con módulos app.config.ts, bootstrapApplication 2
standalone: false Componente declarado en un módulo Quitar esa línea e importar lo que use 2
*ngIf, *ngFor, [ngSwitch] Directivas estructurales @if, @for, @switch 3
@Input(), @Output() Decoradores de entradas y salidas input(), output(), model() 4
constructor(private http: HttpClient) Inyección por constructor inject() 4
zone.js en polyfills, NgZone Detección de cambios con zonas Zoneless y señales 5
canActivate: [MiGuard] con una clase Guarda de clase Guarda funcional 6
app.component.ts, AppComponent Sufijos en los nombres app.ts, App 2
@angular-devkit/build-angular, karma.conf.js Herramientas antiguas @angular/build, Vitest 6

💡 Consejo: La primera orden en un proyecto heredado es ng version, y la segunda, mirar package.json. Te dicen la versión real, que es lo que determina qué camino de actualización tendrás que seguir.


2. NgModule: la arquitectura clásica

Hoy cada componente dice qué necesita en su propio imports. Antes, esa información vivía en una clase aparte, el módulo, marcada con @NgModule. Un componente no podía usarse si no pertenecía a un módulo.

Este es el módulo raíz de la aplicación clásica de este tema:

// src/app/app.module.ts
import { provideHttpClient } from '@angular/common/http';
import { NgModule, provideZoneChangeDetection } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { AvisoComponent } from './aviso/aviso.component';
import { ListaEmpleadosComponent } from './lista-empleados/lista-empleados.component';
import { ResaltarDirective } from './resaltar.directive';
import { SalarioPipe } from './salario.pipe';
import { TarjetaEmpleadoComponent } from './tarjeta-empleado/tarjeta-empleado.component';

@NgModule({
  declarations: [
    AppComponent,
    AvisoComponent,
    ListaEmpleadosComponent,
    ResaltarDirective,
    SalarioPipe,
    TarjetaEmpleadoComponent,
  ],
  imports: [BrowserModule, FormsModule, AppRoutingModule],
  providers: [provideZoneChangeDetection(), provideHttpClient()],
  bootstrap: [AppComponent],
})
export class AppModule {}

Cada propiedad responde a una pregunta:

Propiedad Pregunta Qué contiene
declarations ¿Qué es mío? Componentes, directivas y pipes de este módulo. Cada uno pertenece a un solo módulo
imports ¿Qué necesito de fuera? Otros módulos; sus exports quedan disponibles en todas mis plantillas
exports ¿Qué enseño a quien me importe? Parte de mis declaraciones, o módulos enteros
providers ¿Qué servicios ofrezco? Proveedores para el inyector
bootstrap ¿Con qué componente arranco? Solo en el módulo raíz

A la izquierda, un AppModule con declarations, imports, providers y bootstrap; a la derecha, cada componente con sus propios imports y un app.config.ts

Figura 1. Con módulos, todas las declaraciones comparten lo importado. Con standalone, cada componente declara lo suyo.

La diferencia de fondo es de ámbito. Lo que importa un módulo vale para todas sus declaraciones. Es cómodo, pero tiene dos costes: al mirar un componente no sabes de dónde viene <app-tarjeta-empleado> o salario, y hay que ir a buscar el módulo. Además, el módulo crea un acoplamiento: para reutilizar un componente en otro sitio hay que importar su módulo entero.

El arranque

El punto de entrada también cambia. En lugar de bootstrapApplication(App, appConfig), la aplicación clásica arranca el módulo:

// src/main.ts
import { platformBrowser } from '@angular/platform-browser';
import { AppModule } from './app/app.module';

platformBrowser()
  .bootstrapModule(AppModule)
  .catch((err) => console.error(err));

En código más antiguo verás platformBrowserDynamic de @angular/platform-browser-dynamic. Hace el mismo papel, y es una de las primeras cosas que indican que el proyecto viene de lejos.

standalone: false

Hoy un componente es standalone salvo que digas lo contrario. Por eso, todo lo que se declare en un módulo debe llevar standalone: false:

// src/app/tarjeta-empleado/tarjeta-empleado.component.ts (cabecera)
@Component({
  selector: 'app-tarjeta-empleado',
  standalone: false,
  templateUrl: './tarjeta-empleado.component.html',
  styleUrls: ['./tarjeta-empleado.component.css'],
})

Esta línea es la huella de una actualización: nadie la escribe a mano en código nuevo. Si la quitas sin más, el compilador protesta:

[ERROR] NG6008: Component SinMarcaComponent is standalone, and cannot be declared in an NgModule. Did you mean to import it instead?

El otro error típico aparece si usas en una plantilla un componente que su módulo no conoce. Te lo encontrarás al mover componentes de un módulo a otro:

[ERROR] NG8001: 'app-no-existe' is not a known element:
1. If 'app-no-existe' is an Angular component, then verify that it is part of this module.
2. If 'app-no-existe' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component

La solución casi nunca es añadir CUSTOM_ELEMENTS_SCHEMA, aunque el mensaje lo sugiera: eso solo silencia el error. Lo correcto es declarar el componente o importar el módulo que lo exporta.

Fíjate también en dos detalles de época: styleUrls en plural con un array, cuando hoy basta styleUrl, y el sufijo .component.ts junto a la clase TarjetaEmpleadoComponent. Siguen siendo válidos. La convención cambió, como viste en el módulo 03, pero no hay ninguna obligación de renombrar.

Módulos de rutas: forRoot y forChild

Las rutas también se montan con módulos:

// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

import { SesionGuard } from './sesion.guard';
import { ListaEmpleadosComponent } from './lista-empleados/lista-empleados.component';

const rutas: Routes = [
  { path: '', redirectTo: 'empleados', pathMatch: 'full' },
  { path: 'empleados', component: ListaEmpleadosComponent },
  {
    path: 'departamentos',
    canActivate: [SesionGuard],
    loadChildren: () =>
      import('./departamentos/departamentos.module').then((m) => m.DepartamentosModule),
  },
];

@NgModule({
  imports: [RouterModule.forRoot(rutas)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

RouterModule.forRoot(rutas) hace dos cosas a la vez: registra las rutas y crea los servicios del router. Es el equivalente de provideRouter(rutas). El exports: [RouterModule] reexporta las directivas, para que routerLink y <router-outlet> funcionen en los componentes de quien importe este módulo.

La carga perezosa apunta a un módulo, no a un componente:

// src/app/departamentos/departamentos.module.ts
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

import { ListaDepartamentosComponent } from './lista-departamentos.component';

const rutas: Routes = [{ path: '', component: ListaDepartamentosComponent }];

@NgModule({
  declarations: [ListaDepartamentosComponent],
  imports: [CommonModule, RouterModule.forChild(rutas)],
})
export class DepartamentosModule {}

forChild solo registra rutas, sin crear otro router: por eso cada módulo perezoso lo usa en lugar de forRoot. Y aquí aparece CommonModule, que aporta *ngIf, *ngFor y los pipes comunes. El módulo raíz no lo importa porque BrowserModule ya lo reexporta. Si lo olvidas en un módulo de funcionalidad, las directivas de sus plantillas no funcionan.

📘 Concepto: forRoot y forChild son un patrón, no una función del router: métodos estáticos que devuelven un ModuleWithProviders, es decir, el módulo junto con sus proveedores. Lo verás también en bibliotecas antiguas, como TranslateModule.forRoot(), de @ngx-translate/core. Por convenio, forRoot se llama una sola vez, en la raíz. Llamarlo también en un módulo perezoso crearía una segunda copia de los servicios.

Módulos compartidos

En proyectos grandes encontrarás SharedModule, un módulo que declara y exporta lo reutilizable, y CoreModule, que agrupa los servicios únicos. Son convenciones, no piezas del framework:

// Patrón habitual en código antiguo
@NgModule({
  declarations: [TarjetaComponent, SalarioPipe],
  imports: [CommonModule],
  exports: [CommonModule, TarjetaComponent, SalarioPipe],
})
export class SharedModule {}

Quien importa SharedModule recibe todo lo exportado, lo use o no. Es práctico, pero engorda el código de cada pantalla, porque el empaquetador tiene más difícil saber qué sobra. Esa es una de las razones por las que el modelo standalone acabó sustituyendo a este.


3. Directivas estructurales: *ngIf, *ngFor y ngSwitch

Esta es la plantilla de la lista de empleados clásica:

<!-- src/app/lista-empleados/lista-empleados.component.html -->
<h2>Empleados</h2>
<input [(ngModel)]="filtro" placeholder="Filtrar por nombre" />
<span class="reloj">En pantalla desde hace {{ segundos }} s</span>

<p *ngIf="error">{{ error }}</p>
<p *ngIf="cargando; else lista">Cargando...</p>

<ng-template #lista>
  <ul>
    <li *ngFor="let e of visibles; let i = index; trackBy: porId" [ngClass]="{ par: i % 2 === 0 }">
      <app-tarjeta-empleado
        [empleado]="e"
        data-titulo="Ficha"
        (elegido)="seleccionar($event)"
      ></app-tarjeta-empleado>
    </li>
  </ul>
</ng-template>

<div [ngSwitch]="elegido?.deptId">
  <p *ngSwitchCase="10">Dirección</p>
  <p *ngSwitchCase="undefined">Pulsa una tarjeta</p>
  <p *ngSwitchDefault>Departamento {{ elegido?.deptId }}</p>
</div>
<p [ngStyle]="{ color: elegido ? '#283593' : '#90a4ae' }">Comisión: {{ elegido?.comision ?? 'sin datos' }}</p>
<ng-container #hueco></ng-container>

Lo que debes saber leer:

Clásico Moderno Diferencia que importa
*ngIf="cond" @if (cond) { }  
*ngIf="cond; else b" con <ng-template #b> @if (cond) { } @else { } El bloque else vive lejos del if y se enlaza por nombre
*ngIf="valor$ \| async as v" @if (valor(); as v) { }  
*ngFor="let e of lista; trackBy: fn" @for (e of lista; track e.id) { } trackBy era opcional y pedía un método; track es obligatorio y admite una expresión
let i = index $index También first, last, even, odd, ahora con $
Sin equivalente directo @empty { } Antes hacía falta otro *ngIf
[ngSwitch] con *ngSwitchCase @switch con @case ngSwitchCase compara con ===
[ngClass]="{ par: cond }" [class.par]="cond"  
[ngStyle]="{ color: c }" [style.color]="c"  

Qué significa el asterisco

El * no es magia: es azúcar sintáctico. El compilador reescribe *ngIf como un <ng-template> con la directiva encima:

<!-- Lo que escribes -->
<p *ngIf="error">{{ error }}</p>

<!-- Lo que entiende el compilador -->
<ng-template [ngIf]="error">
  <p>{{ error }}</p>
</ng-template>

Y NgIf es una directiva corriente que crea o destruye esa plantilla según el valor de la condición. Esto explica dos reglas antiguas que te encontrarás:

  • No se puede poner dos * en un mismo elemento, porque habría que envolver el elemento en dos plantillas a la vez y el compilador no sabría en qué orden. Por eso verás <ng-container *ngIf="..."> envolviendo un <li *ngFor="...">.
  • *ngIf y compañía hay que importarlos. Son directivas de CommonModule, no parte del lenguaje de plantillas. En cambio, @if y @for son sintaxis del compilador: no se importan. En un componente standalone, olvidar el import no impide compilar; solo da un aviso que es fácil pasar por alto:
[WARNING] NG8103: The `*ngIf` directive was used in the template, but neither the `NgIf` directive nor the `CommonModule` was imported. Use Angular's built-in control flow @if or make sure that either the `NgIf` directive or the `CommonModule` is included in the `@Component.imports` array of this component.

También explica una ventaja real del flujo de control moderno: como @if lo entiende el compilador, puede estrechar tipos. Y *ngFor era una directiva que comparaba listas con un IterableDiffer; @for usa un algoritmo dentro del propio motor de renderizado.

⚠️ Importante: En los tipos de Angular 22, NgIf, NgForOf y NgSwitch llevan la marca @deprecated 20.0. Obsoleto significa que siguen funcionando y que algún día se quitarán: no hay prisa, pero no escribas código nuevo con ellos. NgClass y NgStyle no están obsoletas: siguen siendo legítimas si necesitas pasar un objeto dinámico entero.

Olvidar trackBy era un fallo de rendimiento muy común: sin él, si llega una lista nueva con objetos nuevos, Angular destruye y vuelve a crear todo el DOM. Por eso @for hizo obligatorio track, como viste en el módulo 04.


4. Decoradores y constructor: @Input, @Output y compañía

La tarjeta de empleado reúne casi todos los decoradores clásicos:

// src/app/tarjeta-empleado/tarjeta-empleado.component.ts
import { Component, EventEmitter, HostBinding, Input, OnChanges, Output, SimpleChanges } from '@angular/core';

import { Empleado } from '../api';

@Component({
  selector: 'app-tarjeta-empleado',
  standalone: false,
  templateUrl: './tarjeta-empleado.component.html',
  styleUrls: ['./tarjeta-empleado.component.css'],
})
export class TarjetaEmpleadoComponent implements OnChanges {
  @Input() empleado!: Empleado;
  @Input() titulo = 'Empleado';
  @Output() elegido = new EventEmitter<Empleado>();

  @HostBinding('class.directivo') directivo = false;

  ngOnChanges(cambios: SimpleChanges): void {
    if (cambios['empleado']) {
      this.directivo = this.empleado.salario >= 4000;
    }
  }
}

Y así se traducen a lo que ya conoces:

Clásico Moderno Qué cambia de verdad
@Input() titulo = 'x' titulo = input('x') Se lee llamando: titulo(). Es una señal, y computed puede depender de ella
@Input() empleado!: Empleado empleado = input.required<Empleado>() El ! solo callaba a TypeScript. required lo comprueba de verdad al compilar
@Input({ transform }) input(valor, { transform })  
@Output() x = new EventEmitter<T>() x = output<T>() output no es un Subject: no puedes llamar a .next ni tratarlo como flujo
@Input() v + @Output() vChange v = model() Dos decoradores en uno, con [(v)]
@ViewChild('h') h!: ElementRef h = viewChild<ElementRef>('h') La señal evita el «aún es undefined» de antes de ngAfterViewInit
@ContentChild, @ViewChildren contentChild, viewChildren  
@HostBinding('class.x') x host: { '[class.x]': 'x()' } En los metadatos del componente
@HostListener('click') host: { '(click)': 'pulsar()' }  
ngOnChanges computed, effect directivo = computed(() => this.empleado().salario >= 4000)
constructor(private s: Servicio) private s = inject(Servicio) La dependencia es un campo más. También sirve fuera de clases, en guardas e interceptores funcionales

El caso de ngOnChanges merece atención. El método se ejecuta cada vez que Angular actualiza una entrada, recibe un objeto SimpleChanges con los valores anterior y nuevo, y su clave es una cadena: cambios['empleado']. Es propenso a errores y mezcla en un solo método reacciones a entradas distintas. Con señales, la relación entre empleado y directivo se escribe como lo que es, un valor derivado.

Directivas y servicios clásicos

Una directiva clásica se ve así. Observa la inyección por constructor, que usa la abreviatura de TypeScript private en el parámetro para crear y asignar el campo a la vez:

// src/app/resaltar.directive.ts
import { Directive, ElementRef, HostListener, Input, Renderer2 } from '@angular/core';

@Directive({ selector: '[appResaltar]', standalone: false })
export class ResaltarDirective {
  @Input('appResaltar') color = '#e8eaf6';

  constructor(
    private el: ElementRef<HTMLElement>,
    private renderer: Renderer2,
  ) {}

  @HostListener('mouseenter') entrar() {
    this.renderer.setStyle(this.el.nativeElement, 'background', this.color);
  }

  @HostListener('mouseleave') salir() {
    this.renderer.removeStyle(this.el.nativeElement, 'background');
  }
}

@Input('appResaltar') usa un alias: dentro de la clase la entrada se llama color, y en la plantilla, appResaltar. El equivalente moderno es input('#e8eaf6', { alias: 'appResaltar' }).

El servicio tiene la misma forma que ya conoces, con @Injectable y una dependencia en el constructor:

// src/app/empleados.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, map } from 'rxjs';

import { API, Empleado, Pagina } from './api';

@Injectable({ providedIn: 'root' })
export class EmpleadosService {
  constructor(private http: HttpClient) {}

  listar(): Observable<Empleado[]> {
    return this.http
      .get<Pagina<Empleado>>(`${API}/empleados?tamano=6`)
      .pipe(map((pagina) => pagina.contenido));
  }
}

En el código aún más antiguo, @Injectable() va sin providedIn y el servicio se registra en los providers de un módulo. Sabrás que es así si @Injectable() aparece vacío: entonces hay que buscar en qué módulo se ofrece, porque eso decide si hay una instancia o varias.

El componente que lo une todo

La lista combina estado en campos normales, una suscripción manual y un temporizador. Fíjate en que ningún campo es una señal: el dato cambia, y la vista se entera por otro camino, que explica la sección siguiente.

// src/app/lista-empleados/lista-empleados.component.ts (parte)
export class ListaEmpleadosComponent implements OnInit, OnDestroy {
  empleados: Empleado[] = [];
  cargando = true;
  error = '';
  filtro = '';
  elegido?: Empleado;
  segundos = 0;
  private reloj?: ReturnType<typeof setInterval>;

  constructor(private servicio: EmpleadosService /* ... */) {}

  ngOnInit(): void {
    // Zone.js parchea setInterval: cada tic provoca una detección de cambios
    this.reloj = setInterval(() => this.segundos++, 1000);

    this.servicio.listar().subscribe({
      next: (lista) => {
        this.empleados = lista;
        this.cargando = false;
      },
      error: () => {
        this.error = 'No se pudo cargar la lista';
        this.cargando = false;
      },
    });
  }

  ngOnDestroy(): void {
    clearInterval(this.reloj);
  }

  get visibles(): Empleado[] {
    const texto = this.filtro.toLowerCase();
    return this.empleados.filter((e) => e.nombre.toLowerCase().includes(texto));
  }

  porId(_indice: number, e: Empleado): number {
    return e.empId;
  }
}

El getter visibles también es típico: se recalcula en cada detección de cambios, aunque nada haya cambiado. Con Zone.js y el reloj de arriba, eso es al menos una vez por segundo. Con un computed solo se recalcularía al cambiar sus dependencias.


5. Zone.js: la magia que había debajo

En un componente clásico cambias un campo normal y la vista se actualiza sola. ¿Cómo se entera Angular, si un campo no avisa a nadie? No se entera del cambio: se entera de que ha terminado algo que podría haberlo causado.

Zone.js es una biblioteca que parchea las API asíncronas del navegador: setTimeout, setInterval, Promise, addEventListener, XMLHttpRequest… Es decir, sustituye esas funciones por versiones suyas que hacen lo mismo y además avisan de cuándo empieza y acaba cada tarea. Angular escucha esos avisos a través de NgZone, y al acabar cualquier tarea recorre el árbol de componentes comparando valores.

Dos flujos: con Zone.js cualquier tarea asíncrona dispara una comprobación del árbol; sin Zone.js, solo una señal o un evento de plantilla marcan la vista afectada

Figura 2. Con Zone.js, Angular comprueba «por si acaso». Sin él, alguien tiene que avisarle.

Las ventajas eran reales: se podía escribir código sin pensar en la detección de cambios. Pero el coste también:

  • Comprobaciones inútiles. Un setInterval de una biblioteca de gráficos obliga a revisar toda la aplicación aunque no toque nada visible.
  • Parches al navegador. async/await nativo no se puede parchear, y por eso el compilador rebaja ese código a generadores cuando hay zonas.
  • Peso y trazas. Un paquete más que descargar, y pilas de llamadas llenas de ZoneDelegate.

Para reconocerlo, busca estas tres cosas:

// angular.json
"polyfills": ["zone.js"]
// En los proveedores
provideZoneChangeDetection()
// En el código, cuando algo debía salir de la zona o volver a ella
this.zona.runOutsideAngular(() => { /* trabajo que no debe disparar comprobaciones */ });
this.zona.run(() => { /* de vuelta dentro */ });

📘 Concepto: Desde Angular 21 las aplicaciones son zoneless por defecto. Sin provideZoneChangeDetection(), aunque Zone.js esté cargado, Angular no lo usa. Por eso la aplicación clásica de este tema tiene que pedirlo de forma explícita en sus providers. Si heredas una aplicación ya actualizada a 21 o 22, lo normal es que ng update lo haya añadido por ti.

En la sección 14 quitaremos Zone.js de la aplicación y veremos exactamente qué deja de pintarse. El módulo 09 explica a fondo la detección de cambios.


6. Otras piezas que encontrarás

Unas cuantas cosas más aparecen a menudo. Todas estas filas compilan en Angular 22 salvo que se indique:

Clásico Moderno Notas
HttpClientModule provideHttpClient() Obsoleto, sigue existiendo
HTTP_INTERCEPTORS con clase withInterceptors([fn]) Si conviven, hace falta withInterceptorsFromDi()
canActivate: [MiGuard] con una clase canActivate: [miGuard] con una función Sigue funcionando, ya que el tipo admite ProviderToken
RouterTestingModule provideRouter en TestBed Hay generador: router-testing-module-migration
async en pruebas, fakeAsync con zonas Temporizadores de Vitest Módulo 26
FormsModule, ReactiveFormsModule Siguen vigentes No son antiguos: módulos 16 y 17
environment.ts con fileReplacements Igual, o configuración en ejecución Módulo 27
Builders de Webpack (@angular-devkit/build-angular:browser) @angular/build:application Obsoletos; ng update @angular/cli --name use-application-builder
ComponentFactoryResolver viewContainerRef.createComponent(Clase) Eliminado en 22, ver sección 10

Un último detalle para no llevarse sorpresas: que algo sea antiguo no significa que esté mal. Formularios reactivos, HttpClient, RxJS o los interceptores no están en retirada. Lo que ha cambiado es cómo se conectan, no su utilidad.


7. La aplicación de ejemplo, en Angular 21

Para el resto del módulo usaremos una aplicación clásica completa, la de los fragmentos anteriores: un AppModule, rutas con forRoot y un módulo perezoso, componentes con standalone: false, *ngIf y *ngFor, decoradores, inyección por constructor y Zone.js. Se creó con ng new de Angular 21 y se reescribió a la manera antigua. Para que la actualización tenga algo que hacer, incluye además varios ficheros en src/app/casos/ con usos que la versión 22 cambia o elimina; los verás en la sección 9.

src/
├── main.ts                         platformBrowser().bootstrapModule(AppModule)
└── app/
    ├── app.module.ts               declarations, imports, providers, bootstrap
    ├── app-routing.module.ts       RouterModule.forRoot + loadChildren
    ├── app.component.ts|html|css
    ├── api.ts                      URL de la API e interfaces
    ├── empleados.service.ts        @Injectable + constructor(private http)
    ├── sesion.guard.ts             guarda de clase
    ├── salario.pipe.ts             pipe con standalone: false
    ├── resaltar.directive.ts       @Input con alias, @HostListener
    ├── aviso/aviso.component.ts    componente que se crea dinámicamente
    ├── tarjeta-empleado/           @Input, @Output, @HostBinding, ngOnChanges
    ├── lista-empleados/            *ngIf, *ngFor, ngSwitch, ComponentFactoryResolver
    ├── departamentos/              módulo perezoso con forChild
    └── casos/                      usos que la versión 22 cambia o elimina

Con ng serve y la API EMPRESA en marcha, la aplicación funciona así en Angular 21:

Aplicación EMPRESA clásica: cabecera, enlaces, un reloj de segundos y seis tarjetas de empleados; la de Luis resaltada y un aviso «Has elegido a Luis»

Figura 3. La aplicación clásica en Angular 21: el reloj avanza gracias a Zone.js, y el aviso inferior lo crea ComponentFactoryResolver.

Fíjate en el contador «En pantalla desde hace 6 s». Lo actualiza un setInterval que modifica un campo normal, sin señales ni markForCheck. Funciona porque Zone.js avisa a Angular en cada tic. Lo usaremos como testigo: si en algún paso deja de avanzar, sabremos que la detección de cambios ha cambiado.

La consola del navegador muestra además un error que es parte de la lección:

ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'null'. Current value: '20'.

Lo provoca la última línea de seleccionar, una llamada a this.cdr.checkNoChanges(). Es un método de depuración que comprueba que la vista no tiene cambios pendientes; llamado en un manejador de eventos, justo después de modificar el estado, siempre los tiene. Es un buen ejemplo de código heredado que «funciona» con un error en la consola que nadie mira. Angular 22 elimina ese método, como veremos.


8. ng update por dentro

En el módulo 01 usaste ng update para mantener un proyecto al día. Aquí veremos qué hace exactamente, porque cuando una actualización sale mal necesitas saber en qué paso estaba.

Cinco pasos: comprobaciones, reinvocar la CLI de destino, actualizar dependencias, ejecutar migraciones y revisar a mano

Figura 4. Las fases de ng update. Las migraciones conservan el comportamiento; lo eliminado sin sustituto queda para ti.

Cuando ejecutas ng update @angular/core@22 @angular/cli@22, ocurre esto:

  1. Comprobaciones. La CLI se niega a seguir si el repositorio tiene cambios sin confirmar, y comprueba que los paquetes pedidos encajan entre sí.
  2. Se reinvoca. Tu CLI instalada es la 21, pero las migraciones de la 22 las conoce la CLI 22. Por eso descarga una CLI 22 temporal y le pasa el trabajo. Es lo que significa el mensaje Installing a temporary Angular CLI versioned 22.
  3. Dependencias. Calcula las versiones nuevas de todo el grupo de paquetes (@angular/core arrastra a common, forms, router, compiler-cli…), y también de las herramientas que dependen de ellos, como TypeScript. Reescribe package.json e instala.
  4. Migraciones. Cada paquete publica una lista de migraciones con la versión a la que pertenecen. La CLI ejecuta, en orden, las que estén entre tu versión de origen y la de destino.
  5. Tú. Compilas, pruebas, revisas el diff y confirmas.

📘 Concepto: Una migración (schematic de actualización) es un programa que analiza tu código con el compilador de TypeScript y lo reescribe. No es un buscar y reemplazar: entiende tipos, sabe qué ?. está en una plantilla y qué componente tiene ya changeDetection. Por eso necesita que el proyecto compile antes de empezar.

Antes de empezar

Comprobación Por qué Cómo
El proyecto compila y las pruebas pasan Las migraciones analizan el código; con errores, pueden saltarse ficheros ng build y ng test
Repositorio limpio ng update se niega si no, y necesitarás comparar git status vacío
Estás en la última versión de tu mayor Las migraciones de la 22 esperan código de 21 ng update @angular/core@21 @angular/cli@21 primero
Node adecuado Angular 22 pide ^22.22.3 \|\| ^24.15.0 \|\| >=26.0.0 node -v
Bibliotecas de terceros listas Si una biblioteca no tiene versión para la 22, la instalación falla por dependencias Revisa Material, NgRx y demás antes
Has leído la guía Lista lo que cambia según tu caso angular.dev/update-guide

⚠️ Importante: Nunca saltes versiones mayores. Para ir de la 19 a la 22 hay que pasar por la 20 y la 21, compilando y probando en cada paso. Cada versión solo trae las migraciones desde la anterior.

Primero, preguntar

Sin argumentos, ng update no cambia nada. Solo dice qué hay:

$ ng update
Using package manager: npm
Collecting installed dependencies...
Found 17 dependencies.
    We analyzed your package.json, there are some packages to update:

      Name                       Version                  Command to update
     ------------------------------------------------------------------------
      @angular/cli               21.2.29 -> 22.2.5        ng update @angular/cli
      @angular/core              21.2.25 -> 22.2.4        ng update @angular/core

    There might be additional packages which don't provide 'ng update' capabilities that are outdated.
    You can update the additional packages by running the update command of your package manager.

La última frase es importante: zone.js, vitest, prettier y el resto de paquetes que no son de Angular no se actualizan aquí. Si hace falta, se suben aparte con npm.

Opciones útiles

Opción Para qué
--create-commits (-C) Un commit por la actualización de paquetes y otro por cada migración que cambie algo. Muy recomendable: podrás revisar y deshacer migraciones sueltas
--allow-dirty Permite empezar con cambios sin confirmar. Evítalo
--force Ignora conflictos de dependencias entre pares. Solo si sabes por qué falla
--migrate-only --from 21 --to 22 Vuelve a ejecutar las migraciones sin cambiar versiones. Solo con un paquete
--name <migración> Ejecuta una migración concreta, por ejemplo las opcionales
--verbose Más detalle cuando algo falla

9. De 21 a 22 con ng update, migración a migración 🔄 v22.0

🔄 Cambia en Angular 22.0. Todo este apartado describe el paso de la 21 a la 22. Si usas la LTS: no te afecta; mantén el proyecto en la última 21.2 con ng update @angular/core@21 @angular/cli@21, que solo aplica correcciones, y vuelve aquí cuando decidas subir.

Con el repositorio limpio, lanzamos la actualización sobre la aplicación clásica:

ng update @angular/core@22 @angular/cli@22 --create-commits

Esta es la salida real, sin las rutas locales:

The installed Angular CLI version is outdated.
Installing a temporary Angular CLI versioned 22 to perform the update.
Using package manager: npm
Collecting installed dependencies...
Found 17 dependencies.
Fetching dependency metadata from registry...
Updating package.json with dependency @angular/common to version 22.2.4...
Updating package.json with dependency @angular/compiler to version 22.2.4...
Updating package.json with dependency @angular/core to version 22.2.4...
Updating package.json with dependency @angular/forms to version 22.2.4...
Updating package.json with dependency @angular/platform-browser to version 22.2.4...
Updating package.json with dependency @angular/router to version 22.2.4...
Updating package.json with dependency @angular/build to version 22.2.5...
Updating package.json with dependency @angular/cli to version 22.2.5...
Updating package.json with dependency @angular/compiler-cli to version 22.2.4...
Updating package.json with dependency typescript to version 6.0.3...
✔ Cleaning node modules directory
✔ Installing packages
  Committed migration step (c67b26b6c): Angular CLI update for packages - @angular/core@22, @angular/cli@22.
** Executing migrations of package '@angular/core' **

❯ Adds the required third argument to canMatch callsites.
UPDATE src/app/casos/guardia.ts (402 bytes)
  Migration completed (1 file modified).
  Committed migration step (253224601): @angular/core migration - can-match-snapshot-required.

❯ Adds `ChangeDetectionStrategy.Eager` to all components.
UPDATE src/app/aviso/aviso.component.ts (389 bytes)
UPDATE src/app/lista-empleados/lista-empleados.component.ts (2012 bytes)
UPDATE src/app/departamentos/lista-departamentos.component.ts (420 bytes)
UPDATE src/app/app.component.ts (329 bytes)
UPDATE src/app/tarjeta-empleado/tarjeta-empleado.component.ts (774 bytes)
UPDATE src/app/casos/modelo-doble.component.ts (385 bytes)
  Migration completed (6 files modified).
  Committed migration step (b39f6b5cb): @angular/core migration - change-detection-eager.

❯ Adds 'withXhr' to 'provideHttpClient' function calls when the 'HttpXhrBackend' is used.
UPDATE src/app/app.module.ts (1052 bytes)
UPDATE src/app/casos/hidratacion.ts (325 bytes)
  Migration completed (2 files modified).
  Committed migration step (9d182367c): @angular/core migration - http-xhr-backend.

❯ Adds withNoIncrementalHydration() opt out to provideClientHydration() when incremental hydration is not enabled to retain pre-v22 behavior-.
UPDATE src/app/casos/hidratacion.ts (397 bytes)
  Migration completed (1 file modified).
  Committed migration step (ca352d2b2): @angular/core migration - incremental-hydration.

❯ Migrate broken duplicate outputs.
UPDATE src/app/casos/modelo-doble.component.ts (473 bytes)
  Migration completed (1 file modified).
  Committed migration step (15358a50d): @angular/core migration - model-output.

❯ Wraps optional chaining expressions in $safeNavigationMigration().
UPDATE src/app/lista-empleados/lista-empleados.component.html (915 bytes)
  Migration completed (1 file modified).
  Committed migration step (e5fd121e6): @angular/core migration - safe-optional-chaining.

❯ Disables the 'nullishCoalescingNotNullable & optionalChainNotNullable extended diagnostics.
UPDATE tsconfig.app.json (647 bytes)
  Migration completed (1 file modified).
  Committed migration step (6e8ffb332): @angular/core migration - strict-safe-navigation-narrow.

❯ Adds 'strictTemplates: false' in tsconfig.json when not set.
  Migration completed (No changes made).

** Executing migrations of package '@angular/cli' **

❯ Add 'istanbul-lib-instrument' to 'devDependencies' if Karma unit testing is used.
  Migration completed (No changes made).
❯ Add 'trustProxyHeaders' configuration to 'AngularNodeAppEngine' or 'AngularAppEngine'.
  Migration completed (No changes made).
❯ Update the angular workspace configuration.
  Migration completed (No changes made).

** Optional migrations of package '@angular/cli' **

This package has 2 optional migrations that can be executed.
❯ Migrate projects using legacy Karma unit-test builder to the new unit-test builder with Vitest.
  ng update @angular/cli --name migrate-karma-to-vitest

❯ Migrate application projects to the new build system.
  ng update @angular/cli --name use-application-builder

Y el historial queda así, con un commit por paso:

$ git log --oneline
6e8ffb3 @angular/core migration - strict-safe-navigation-narrow
e5fd121 @angular/core migration - safe-optional-chaining
15358a5 @angular/core migration - model-output
ca352d2 @angular/core migration - incremental-hydration
9d18236 @angular/core migration - http-xhr-backend
b39f6b5 @angular/core migration - change-detection-eager
2532246 @angular/core migration - can-match-snapshot-required
c67b26b Angular CLI update for packages - @angular/core@22, @angular/cli@22
291e09f zone.js y aplicacion clasica

Ahora veamos qué ha hecho cada una, con el diff real.

Las dependencias: package.json

-    "@angular/core": "^21.2.0",
+    "@angular/core": "^22.2.4",
     ...
-    "@angular/cli": "^21.2.29",
+    "@angular/cli": "^22.2.5",
-    "typescript": "~5.9.2",
+    "typescript": "~6.0.3",

TypeScript sube a la 6, la única que admite Angular 22 (>=6.0 <6.1). Es un cambio que puede romper tu propio código, independientemente de Angular: el módulo 02 explica qué cambia en TypeScript 6. vitest, jsdom y zone.js se quedan donde estaban.

change-detection-eager: conservar la detección de siempre

-import { Component } from '@angular/core';
+import { Component, ChangeDetectionStrategy } from "@angular/core";

 @Component({
   selector: "app-root",
   standalone: false,
   templateUrl: "./app.component.html",
+  changeDetection: ChangeDetectionStrategy.Eager,
   styleUrls: ["./app.component.css"],
 })

En Angular 22, un componente sin changeDetection usa OnPush. La aplicación clásica cuenta con lo contrario en todas partes: campos normales cambiados desde subscribe y setInterval. La migración escribe en cada componente la estrategia de antes, Eager (el nuevo nombre de Default), y así nada cambia. Solo toca componentes: directivas y pipes no tienen estrategia de detección. El módulo 09 explica las dos estrategias.

http-xhr-backend: seguir usando XHR

-  providers: [provideZoneChangeDetection(), provideHttpClient()],
+  providers: [provideZoneChangeDetection(), provideHttpClient(withXhr())],
-export const conSsr = [provideClientHydration(), provideHttpClient(withInterceptors([]))];
+export const conSsr = [
+  provideClientHydration(),
+  provideHttpClient(withXhr(), withInterceptors([])),
+];
 export const conFetch = [provideHttpClient(withFetch())];

En la 22, HttpClient usa fetch por defecto. Para no cambiar el comportamiento, la migración añade withXhr() a cada provideHttpClient que usaba XHR. Fíjate en que respeta la línea que ya tenía withFetch(): esa ya usaba fetch. El módulo 12 explica cuándo necesitas de verdad XHR: para el progreso de subida.

incremental-hydration: sin hidratación incremental

-  provideClientHydration(),
+  provideClientHydration(withNoIncrementalHydration()),

Con SSR, provideClientHydration() activa en la 22 la hidratación incremental. La migración la desactiva explícitamente para conservar el comportamiento de la 21. El módulo 24 cuenta qué ganas si la quitas.

can-match-snapshot-required: un tercer argumento, y un error

CanMatchFn y la interfaz CanMatch reciben en la 22 un tercer parámetro obligatorio, el estado parcial de la ruta. La migración busca llamadas a canMatch y les añade un tercer argumento:

-  return guardia.canMatch(ruta, segmentos);
+  return guardia.canMatch(ruta, segmentos, {} as any /* added by migration */);

{} as any es un relleno para que compile, con un comentario para que lo encuentres. Pero en nuestro caso no compila: la clase SoloAdmin declaraba su método con dos parámetros, y TypeScript no deja llamarlo con tres. Lo verás en la sección 10. La lección es general: el código que generan las migraciones es un punto de partida, no un resultado revisado.

model-output: una salida duplicada

model('valor') ya crea una salida valorChange. Si además declarabas a mano valorChange = output(), hay dos salidas con el mismo nombre. La migración separa la entrada y el estado local:

-  valor = model(0);
+  valorInput = input(0, { alias: "valor" });
+  valor = linkedSignal(this.valorInput);
   valorChange = output<number>();

El componente conserva la entrada valor y su salida, y valor sigue siendo escribible dentro, gracias a linkedSignal (módulo 05).

safe-optional-chaining: ?. sigue dando null

En las plantillas de Angular 21, a?.b daba null cuando a no existía; en la 22 da undefined, como en JavaScript. Casi nunca importa, pero a veces sí. La migración envuelve solo las expresiones en que el cambio podría notarse:

-<div [ngSwitch]="elegido?.deptId">
+<div [ngSwitch]="$safeNavigationMigration(elegido?.deptId)">
   <p *ngSwitchCase="10">Dirección</p>
   <p *ngSwitchCase="undefined">Pulsa una tarjeta</p>
   <p *ngSwitchDefault>Departamento {{ elegido?.deptId }}</p>

En cambio, ha dejado sin envolver {{ elegido?.comision ?? 'sin datos' }}: con ?? delante, null y undefined dan lo mismo. Y no ha tocado la interpolación {{ elegido?.deptId }}, porque ambos valores se pintan como texto vacío.

El ngSwitch sí importa, y esconde un fallo que la aplicación tenía desde siempre. ngSwitchCase compara con ===, así que en la 21 el null de elegido?.deptId nunca era igual a undefined: antes de elegir una tarjeta se veía «Departamento» en lugar de «Pulsa una tarjeta». La migración mantiene el fallo, porque su trabajo es no cambiar nada. Cuando quites el envoltorio, en la sección 11, el texto correcto aparecerá por fin.

strict-safe-navigation-narrow: callar dos diagnósticos

"angularCompilerOptions": {
  "extendedDiagnostics": {
    "checks": {
      "nullishCoalescingNotNullable": "suppress",
      "optionalChainNotNullable": "suppress"
    }
  }
}

En la 22, estos dos diagnósticos extendidos (avisan de un ?. o un ?? innecesarios porque el valor nunca es nulo) están activos. En código antiguo saltarían por todas partes, y la migración los silencia en tsconfig.app.json. Cuando tengas tiempo, bórralos y corrige los avisos uno a uno.

Las que no hicieron nada

  • strict-templates-default: en la 22 strictTemplates está activo aunque no se escriba. La migración añade "strictTemplates": false a los proyectos que no lo tenían, para no llenarlos de errores nuevos. Nuestro tsconfig.json ya decía true, así que no ha hecho falta.
  • Migraciones de la CLI: istanbul-lib-instrument para Karma, trustProxyHeaders para SSR y la configuración del espacio de trabajo. Ninguna aplicaba.
  • Opcionales: migrate-karma-to-vitest y use-application-builder. No se ejecutan solas: si tu proyecto usa Karma o los builders de Webpack, lánzalas con ng update @angular/cli --name ... en un paso aparte.

Un efecto secundario: el formato

Si comparas los diffs completos, verás que las migraciones también han cambiado las comillas simples por dobles y han partido líneas largas. Cuando el proyecto tiene Prettier instalado, los ficheros que toca una migración salen formateados con él. En un proyecto recién creado con ng new hay un .prettierrc con singleQuote: true y el cambio pasa inadvertido; nuestro proyecto de prueba no lo tenía, y Prettier aplicó sus valores por defecto. Si tu equipo usa otro formateador, ejecútalo tras la actualización y confirma ese cambio por separado para que no ensucie la revisión.

💡 Consejo: Revisa los commits de --create-commits uno a uno con git show <commit>. Si una migración ha hecho algo que no te convence, git revert <commit> la deshace sin tocar las demás.


10. Lo que ninguna migración arregla 🔄 v22.0

🔄 Cambia en Angular 22.0. Estas API se eliminan en la 22 y el código que las usa deja de compilar. Si usas la LTS: siguen existiendo, aunque casi todas obsoletas; cambiarlas ya por su sustituto, que también existe en la 21, te deja el proyecto listo para actualizar.

Tras la actualización, toca compilar:

$ ng build
Application bundle generation failed. [42.495 seconds]
X [ERROR] TS2305: Module '"@angular/core"' has no exported member 'ComponentFactoryResolver'.
    src/app/lista-empleados/lista-empleados.component.ts:4:2
X [ERROR] TS18046: 'ref.instance' is of type 'unknown'.
    src/app/lista-empleados/lista-empleados.component.ts:74:4
X [ERROR] TS2339: Property 'checkNoChanges' does not exist on type 'ChangeDetectorRef'.
    src/app/lista-empleados/lista-empleados.component.ts:75:13
X [ERROR] NG2003: No suitable injection token for parameter 'resolver' of class 'ListaEmpleadosComponent'.
    src/app/lista-empleados/lista-empleados.component.ts:36:12
X [ERROR] NG6001: The class 'ListaEmpleadosComponent' is listed in the declarations of the NgModule 'AppModule', but is not a directive, a component, or a pipe.
    src/app/app.module.ts:18:4
X [ERROR] TS2554: Expected 2 arguments, but got 3.
    src/app/casos/guardia.ts:14:43
X [ERROR] TS2724: '"@angular/router"' has no exported member named 'provideRoutes'. Did you mean 'provideRouter'?
    src/app/casos/rutas-antiguas.ts:1:9

Siete errores, pero solo cuatro causas. Es típico: un error de verdad arrastra otros. NG2003 y NG6001 desaparecen en cuanto se arregla el primero, porque el compilador no puede analizar un componente cuyo constructor pide un tipo que no existe. Lee siempre los errores de arriba abajo y vuelve a compilar tras cada arreglo.

ComponentFactoryResolver y checkNoChanges

Desde hace años, ViewContainerRef.createComponent acepta directamente la clase del componente, así que la fábrica sobra:

// Antes (21)
const fabrica = this.resolver.resolveComponentFactory(AvisoComponent);
this.hueco.clear();
const ref = this.hueco.createComponent(fabrica);
ref.instance.texto = 'Has elegido a ' + e.nombre;
this.cdr.checkNoChanges();
// Después (22)
this.hueco.clear();
const ref = this.hueco.createComponent(AvisoComponent);
ref.setInput('texto', 'Has elegido a ' + e.nombre);

Además de quitar resolver y cdr del constructor, hemos cambiado ref.instance.texto = ... por ref.setInput(...). No es obligatorio, pero setInput avisa a Angular de que la entrada ha cambiado, como haría una plantilla; asignar el campo directamente no lo hace, y con OnPush el aviso no se pintaría. checkNoChanges no tiene sustituto en ChangeDetectorRef: era una herramienta de depuración, y en desarrollo Angular ya hace esa comprobación tras cada ciclo. En pruebas, ComponentFixture sigue teniendo su propio checkNoChanges().

canMatch con tres parámetros

La migración añadió el tercer argumento a la llamada, pero no al método. Lo correcto es declarar el parámetro y pasar un valor real en lugar de {} as any:

// src/app/casos/guardia.ts (22)
import { CanMatch, PartialMatchRouteSnapshot, Route, UrlSegment } from '@angular/router';

export class SoloAdmin implements CanMatch {
  canMatch(ruta: Route, segmentos: UrlSegment[], actual: PartialMatchRouteSnapshot): boolean {
    return segmentos.length > 0 && ruta.path === 'admin';
  }
}

export function comprobar(guardia: SoloAdmin, ruta: Route, segmentos: UrlSegment[], actual: PartialMatchRouteSnapshot) {
  return guardia.canMatch(ruta, segmentos, actual);
}

Si tus guardas son funciones CanMatchFn y nadie las llama a mano, este cambio no te afecta: es el router quien les pasa los argumentos.

provideRoutes

provideRoutes(rutas) registraba rutas adicionales a través del token ROUTES. Si lo usaba una biblioteca o un módulo antiguo, el equivalente directo es el proveedor que había dentro:

// src/app/casos/rutas-antiguas.ts (22)
import { Provider } from '@angular/core';
import { ROUTES, Routes } from '@angular/router';

// provideRoutes(rutas) desaparece en 22: era exactamente este proveedor
export function proveerRutas(rutas: Routes): Provider {
  return { provide: ROUTES, multi: true, useValue: rutas };
}

En código de aplicación, lo normal es que esas rutas puedan ir directamente en provideRouter o en loadChildren.

Con los tres arreglos, la aplicación compila, las pruebas pasan y funciona igual que en la 21: el reloj avanza, las tarjetas se cargan y el aviso aparece. Y ya no hay error NG0100 en la consola, porque se fue con checkNoChanges.

La misma aplicación EMPRESA tras actualizar a Angular 22: idéntica a la de la 21, con el reloj, las seis tarjetas y el aviso

Figura 5. Tras ng update y tres arreglos a mano, la aplicación clásica funciona en Angular 22 sin cambios visibles. Sigue siendo una aplicación con NgModule y Zone.js.

Tabla de lo eliminado en Angular 22

Esta es la lista completa, comprobada en las definiciones de tipos de 21.2 y 22.2:

Eliminado Paquete Sustituto Existe el sustituto en 21
ComponentFactoryResolver, ComponentFactory core viewContainerRef.createComponent(Clase) o createComponent(Clase, { environmentInjector }) Sí
createComponent(fabrica) en ViewContainerRef core createComponent(Clase) Sí
ModuleWithComponentFactories core Nada: era parte de la compilación JIT de módulos  
createNgModuleRef core createNgModule Sí
ChangeDetectorRef.checkNoChanges() core Ninguno en aplicación; fixture.checkNoChanges() en pruebas Sí
provideRoutes router { provide: ROUTES, multi: true, useValue } o provideRouter Sí
HammerModule, HammerGestureConfig, HAMMER_GESTURE_CONFIG, HAMMER_LOADER platform-browser Eventos de puntero nativos (pointerdown, pointermove…) o Hammer.js usado directamente Sí
WithField, WithOptionalField, WithoutField forms/signals WithFieldTree, WithOptionalFieldTree, WithoutFieldTree Sí
getAngularLib, setAngularLib upgrade getAngularJSGlobal, setAngularJSGlobal Sí
TypeScript 5.9 y anteriores   TypeScript >=6.0 <6.1 No: la 21 exige >=5.9 <6.0

Los miembros de clase, como checkNoChanges, no aparecen en las listas de exportaciones; por eso conviene compilar y no fiarse solo de ellas.

Cambios sin aviso al compilar

Lo peor no son los errores, sino lo que compila y se comporta distinto. Estos son los cambios de comportamiento de la 22, y si tienen migración:

Cambio Con migración Qué notarás si no hay protección
OnPush por defecto Sí: Eager Vistas que no se actualizan (sección 11)
fetch por defecto en HttpClient Sí: withXhr() Sin eventos de progreso de subida
Hidratación incremental por defecto Sí: withNoIncrementalHydration() Bloques @defer hidratados de otra forma
?. da undefined en plantillas Sí: $safeNavigationMigration() Comparaciones estrictas con null que cambian
strictTemplates implícito Sí: false explícito si no estaba Errores de tipos nuevos en plantillas
paramsInheritanceStrategy: 'always' No Las rutas hijas heredan los parámetros del padre; en la 21 solo lo hacían las hijas sin path propio (módulo 11)
data- ya no enlaza entradas No data-[titulo]="x" deja de pasar la entrada titulo, sin error
Varios componentes en un mismo elemento No Error de compilación nuevo (módulo 07)
min/max de Signal Forms solo con números No Error de tipos si les pasabas cadenas (módulo 18)

La fila de data- merece un ejemplo, porque se ve poco y falla en silencio. Angular admitía antes el prefijo data- delante de la sintaxis de enlace, para que la plantilla fuera HTML válido: data-[titulo] equivalía a [titulo]. Lo comprobamos con una prueba que pinta el valor de la entrada en las dos versiones:

Plantilla Angular 21 Angular 22
<app-hija data-titulo="estatico"> defecto defecto
<app-hija data-[titulo]="'enlazado'"> enlazado defecto

Un atributo data-titulo normal nunca enlazó la entrada, en ninguna versión: es un atributo HTML y nada más. Lo que deja de funcionar es la forma data-[...] y data-(...). Búscala en el proyecto antes de actualizar.


11. Retirar las protecciones 🔄 v22.0

🔄 Cambia en Angular 22.0. Estas protecciones solo existen tras actualizar a la 22. Si usas la LTS: no hay nada que retirar; tus componentes ya usan Eager (antes Default) y HttpClient ya usa XHR, y puedes pasar a OnPush componente a componente cuando su estado esté en señales.

La aplicación funciona, pero lleva puesto el comportamiento de la 21. Hay que quitarle esas protecciones, de una en una y comprobando cada paso, porque cada una esconde una decisión que nadie ha tomado aún.

$safeNavigationMigration

Quitamos el envoltorio del ngSwitch:

<div [ngSwitch]="elegido?.deptId">

Al recargar, antes de elegir una tarjeta, el texto pasa de «Departamento» a «Pulsa una tarjeta». Ahora elegido?.deptId da undefined, coincide con *ngSwitchCase="undefined" y se cumple lo que el autor quería. Es un caso real de la utilidad de revisar cada envoltorio: no basta con borrarlos todos a la vez, porque cada uno puede cambiar algo visible.

withXhr()

Si tu aplicación no muestra el progreso de las subidas, withXhr() sobra y puedes volver a provideHttpClient(). Con fetch, las peticiones salen igual y la aplicación sigue funcionando; las diferencias de detalle están en el módulo 12.

ChangeDetectionStrategy.Eager

Esta es la grande. Quitamos la línea de todos los componentes de golpe, para ver qué pasa, y recargamos:

La aplicación tras quitar Eager: el reloj se queda en 0 s y la lista se queda en «Cargando...» para siempre

Figura 6. Sin Eager, los componentes pasan a OnPush: Zone.js sigue avisando, pero Angular no revisa estas vistas porque nada las ha marcado.

El reloj se queda en «0 s» y la lista no sale nunca de «Cargando…». La petición ha llegado y this.empleados tiene los datos, pero la vista no se entera. Zone.js sigue cargado y sigue disparando la detección de cambios tras cada tic y cada respuesta. Lo que ha cambiado es que un componente OnPush solo se revisa si se ha marcado: una entrada nueva, un evento de su plantilla, una señal que lee, o markForCheck(). Asignar un campo desde un subscribe o un setInterval no marca nada.

Así que Eager no se quita con un buscar y reemplazar. Primero hay que cambiar cómo se guarda el estado, con señales, y después retirar Eager componente a componente. Es lo que hacemos en las secciones siguientes. Mientras tanto, deshacemos este último cambio.

⚠️ Importante: Fíjate en que no ha habido ningún error, ni en la compilación ni en la consola. Por eso la migración añade Eager en lugar de dejar que lo descubras en producción.


12. Modernizar con ng generate @angular/core:...

Con la aplicación ya en Angular 22, el siguiente trabajo es opcional pero muy rentable: pasar el código a la sintaxis actual. No hace falta hacerlo a mano. @angular/core incluye generadores (schematics) que reescriben el proyecto, igual que las migraciones de ng update, pero que solo se ejecutan cuando tú los pides.

Generador Alias Qué hace
control-flow-migration control-flow *ngIf, *ngFor, ngSwitch a @if, @for, @switch
standalone-migration standalone Componentes standalone, sin módulos, y arranque con bootstrapApplication
inject-migration inject Inyección por constructor a inject()
service-migration service @Injectable a @Service donde se pueda
signal-input-migration signal-input @Input a input()
output-migration outputs @Output a output()
signal-queries-migration signal-queries @ViewChild y compañía a viewChild()…
signals   Las tres anteriores juntas
route-lazy-loading-migration route-lazy-loading component: a loadComponent: en las rutas
cleanup-unused-imports   Quita lo que sobra en los imports de los componentes
self-closing-tags-migration self-closing-tag <app-x></app-x> a <app-x />
ngclass-to-class-migration ngclass-to-class [ngClass] a enlaces [class.x] cuando se puede
ngstyle-to-style-migration ngstyle-to-style [ngStyle] a enlaces [style.x] cuando se puede
common-to-standalone-migration common-to-standalone CommonModule a las directivas y pipes concretos
router-testing-module-migration   RouterTestingModule a RouterModule en las pruebas

Todos se lanzan igual:

ng generate @angular/core:control-flow

Todos menos cleanup-unused-imports admiten --path para limitarse a una carpeta, algo muy útil en proyectos grandes: puedes modernizar una funcionalidad cada vez.

Seis capas: sintaxis, estructura, inyección, señales en entradas y consultas, estado a señales a mano, y retirar Eager y Zone.js

Figura 7. Un orden razonable: de lo más mecánico a lo que exige pensar. Cada capa en su propio commit.

Los hemos ejecutado todos sobre la aplicación, uno detrás de otro y con un commit por paso. Estos son los resultados reales, incluidos los fallos.

control-flow

$ ng generate @angular/core:control-flow
UPDATE src/app/lista-empleados/lista-empleados.component.html (933 bytes)
UPDATE src/app/departamentos/lista-departamentos.component.ts (443 bytes)
-<p *ngIf="cargando; else lista">Cargando...</p>
-
-<ng-template #lista>
+@if (cargando) {
+  <p>Cargando...</p>
+} @else {
   <ul>
-    <li
-      *ngFor="let e of visibles; let i = index; trackBy: porId"
-      [ngClass]="{ par: i % 2 === 0 }"
-    >
+    @for (e of visibles; track porId(i, e); let i = $index) {
+      <li [ngClass]="{ par: i % 2 === 0 }">
...
-<div [ngSwitch]="elegido?.deptId">
-  <p *ngSwitchCase="10">Dirección</p>
+<div>
+  @switch (elegido?.deptId) {
+    @case (10) {
+      <p>Dirección</p>
+    }

Fíjate en dos detalles. El else con plantilla nombrada se ha convertido en un @else en su sitio, y el <ng-template #lista> ha desaparecido. Y el trackBy: porId se ha traducido literalmente a track porId(i, e): funciona, pero lo natural es track e.empId, y el método porId ya sobra. El generador hace lo seguro; lo idiomático te toca a ti.

standalone, en tres pasadas

La documentación oficial pide ejecutarlo tres veces, cada una con un modo, compilando entre medias:

ng generate @angular/core:standalone --mode=convert-to-standalone
ng build
ng generate @angular/core:standalone --mode=prune-ng-modules
ng generate @angular/core:standalone --mode=standalone-bootstrap
ng build

Primera pasada. Quita standalone: false de cada declaración y le añade los imports que necesita su plantilla:

 @Component({
   selector: "app-lista-empleados",
-  standalone: false,
   templateUrl: "./lista-empleados.component.html",
   styleUrls: ["./lista-empleados.component.css"],
+  imports: [FormsModule, NgClass, TarjetaEmpleadoComponent, NgStyle],
 })

Los módulos se mantienen, pero ahora importan los componentes en lugar de declararlos. La compilación pasa con un aviso interesante:

[WARNING] NG8113: AvisoComponent is not used within the template of TarjetaEmpleadoComponent

El generador había añadido AvisoComponent a los imports de la tarjeta, aunque su plantilla no lo usa: con módulos, todo lo declarado estaba disponible para todos, y el generador no siempre afina. El aviso NG8113 te lo señala, y más adelante cleanup-unused-imports lo quitará.

Segunda pasada. Debería borrar los módulos que han quedado vacíos. En nuestro caso dijo Nothing to be done: AppModule todavía tenía bootstrap y providers, y DepartamentosModule, sus rutas con forChild. Es normal: esta pasada solo elimina módulos que ya no aportan nada.

Tercera pasada. Cambia el arranque y borra el módulo raíz:

DELETE src/app/app.module.ts
UPDATE src/main.ts (634 bytes)
UPDATE src/app/app.component.ts (484 bytes)
// src/main.ts, tal como lo deja el generador
import {
  platformBrowser,
  BrowserModule,
  bootstrapApplication,
} from "@angular/platform-browser";

import { provideZoneChangeDetection, importProvidersFrom } from "@angular/core";
import { provideHttpClient } from "@angular/common/http";
import { FormsModule } from "@angular/forms";
import { AppRoutingModule } from "./app/app-routing.module";
import { AppComponent } from "./app/app.component";

bootstrapApplication(AppComponent, {
  providers: [
    importProvidersFrom(BrowserModule, FormsModule, AppRoutingModule),
    provideZoneChangeDetection(),
    provideHttpClient(),
  ],
}).catch((err) => console.error(err));

Funciona, pero es una traducción literal. importProvidersFrom es el puente para usar los proveedores de un módulo fuera de un módulo. Aquí sobran los tres: BrowserModule no aporta nada con bootstrapApplication, FormsModule ya lo importa el componente que lo usa, y AppRoutingModule se sustituye por provideRouter(rutas). El import de platformBrowser ni siquiera se usa. Lo limpiaremos a mano en la sección 13.

inject

 export class EmpleadosService {
-  constructor(private http: HttpClient) {}
+  private http = inject(HttpClient);
-  constructor(
-    private el: ElementRef<HTMLElement>,
-    private renderer: Renderer2,
-  ) {}
+  private el = inject<ElementRef<HTMLElement>>(ElementRef);
+  private renderer = inject(Renderer2);

Mecánico y fiable. Conserva la visibilidad (private) y el tipo genérico de ElementRef.

service 🆕 v22.1

🆕 Desde Angular 22.1. El generador service-migration no existe en la LTS (Angular 21), ni el decorador @Service, que es de la 22.0. Si usas la LTS: deja @Injectable({ providedIn: 'root' }), que es equivalente; puedes aplicar ya el generador inject para que el cambio sea trivial cuando actualices.

-import { Injectable } from '@angular/core';
+import { Service } from "@angular/core";

-@Injectable({ providedIn: 'root' })
+@Service()
 export class SesionGuard implements CanActivate {

Convierte los @Injectable({ providedIn: 'root' }) que ya no usan inyección por constructor, por eso conviene ejecutar antes inject. Ha convertido tanto el servicio como la guarda de clase: para el generador, las dos son clases inyectables. Que la guarda deba ser una función es otra decisión, y la tomaremos a mano. El módulo 08 explica @Service.

signal-input, outputs y signal-queries

$ ng generate @angular/core:signal-input
    Successfully migrated to signal inputs 🎉
      -> Migrated 4/4 inputs.
$ ng generate @angular/core:outputs
    Successfully migrated to outputs as functions 🎉
      -> Migrated 1 out of 1 detected outputs (100.00 %).
$ ng generate @angular/core:signal-queries
      -> Migrated 1/1 queries.

Lo más valioso es que actualizan también las lecturas. En la tarjeta:

-  @Input() empleado!: Empleado;
-  @Input() titulo = 'Empleado';
-  @Output() elegido = new EventEmitter<Empleado>();
+  readonly empleado = input.required<Empleado>();
+  readonly titulo = input("Empleado");
+  readonly elegido = output<Empleado>();
-<article appResaltar="#c5cae9" (click)="elegido.emit(empleado)">
-  <small>{{ titulo }}</small>
-  <strong>{{ empleado.nombre }} {{ empleado.apellidos }}</strong>
+<article appResaltar="#c5cae9" (click)="elegido.emit(empleado())">
+  <small>{{ titulo() }}</small>
+  <strong>{{ empleado().nombre }} {{ empleado().apellidos }}</strong>

Y dentro de ngOnChanges, this.empleado.salario pasó a this.empleado().salario. Fíjate en que @Input() empleado!: Empleado se ha convertido en input.required: el generador interpreta el ! como «siempre llega». En la directiva, el alias se conserva: input('#e8eaf6', { alias: 'appResaltar' }). Y @ViewChild('hueco', { read: ViewContainerRef }) hueco! pasó a viewChild.required('hueco', { read: ViewContainerRef }), con las lecturas this.hueco cambiadas por this.hueco().

Lo que no tocan: @HostBinding, @HostListener y ngOnChanges siguen ahí. Esas tres piezas se pasan a mano a host y a computed.

💡 Consejo: Si un @Input se escribe desde dentro de la clase, input() no sirve, porque una entrada de señal es de solo lectura. El generador lo detecta y lo deja sin migrar, y la cuenta final («Migrated 4/4 inputs» en nuestro caso) refleja cuántas ha convertido. Los generadores signal-input, signal-queries y signals admiten la opción --insert-todos, que deja un comentario TODO junto a lo que no han podido migrar, y --best-effort-mode, que migra también los casos dudosos para que los revises tú.

route-lazy-loading: un error de compilación

    Number of updated routes: 2
    Number of skipped routes: 0

Convirtió component: ListaEmpleadosComponent en loadComponent: () => import(...), también en el módulo perezoso. Pero al hacerlo borró el import de ListaDepartamentosComponent de departamentos.module.ts, y ese módulo aún lo usaba en sus imports:

X [ERROR] TS2304: Cannot find name 'ListaDepartamentosComponent'.
X [ERROR] NG1010: Value at position 2 in the NgModule.imports of DepartamentosModule is not a reference

ngclass-to-class: un import roto

-      <li [ngClass]="{ par: i % 2 === 0 }">
+      <li [class.par]="i % 2 === 0">

La plantilla quedó perfecta. El fichero TypeScript, no:

-import { NgClass, NgStyle } from "@angular/common";
+import { NgStyle }{ NgStyle }{ NgStyle }{ NgStyle } from "@angular/common";

El propio generador lo avisó al intentar formatear el resultado (SyntaxError: 'from' expected), y ya no compiló. Además, como el fichero ya no se podía analizar, el siguiente generador, ngstyle-to-style, terminó con Migrated 0 ngStyle sin decir por qué.

cleanup-unused-imports y self-closing-tag

    Removed 1 import in 1 file
      -> Migrated 3 components to self-closing tags in 2 component files.

El primero quitó el AvisoComponent que sobraba en la tarjeta (el del aviso NG8113). El segundo cambió <router-outlet></router-outlet> por <router-outlet />, y lo mismo con la tarjeta y el <ng-container #hueco>.

Lo que enseña esta tanda

De catorce pasos, dos rompieron la compilación y uno no hizo nada sin explicar por qué. Ninguno lo dijo con un error claro en el momento. Por eso el procedimiento es tan importante como los generadores:

  1. Un generador cada vez, y un commit después de cada uno.
  2. ng build tras cada paso, o al menos tras cada grupo pequeño. Si falla, git show HEAD te enseña exactamente qué ha tocado el último.
  3. Lee los avisos, no solo los errores: NG8113, Formatting of files failed y Migrated 0 eran pistas.
  4. Revisa a mano lo que deja «funcionando pero literal»: track porId(i, e), importProvidersFrom, el {} as any de la guarda.

13. Lo que queda a mano: estado en señales

Los generadores han cambiado la forma del código, pero no el estado. La lista sigue guardando sus datos en campos normales, y por eso aún depende de Eager y de Zone.js. Este paso no lo automatiza nadie, porque exige decidir qué es estado, qué es derivado y qué es efecto.

La lista de empleados queda así:

// src/app/lista-empleados/lista-empleados.component.ts
import { Component, DestroyRef, ViewContainerRef, computed, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';

import { Empleado } from '../api';
import { AvisoComponent } from '../aviso/aviso.component';
import { EmpleadosService } from '../empleados.service';
import { TarjetaEmpleadoComponent } from '../tarjeta-empleado/tarjeta-empleado.component';

@Component({
  selector: 'app-lista-empleados',
  templateUrl: './lista-empleados.component.html',
  styleUrl: './lista-empleados.component.css',
  imports: [FormsModule, TarjetaEmpleadoComponent],
})
export class ListaEmpleadosComponent {
  private readonly servicio = inject(EmpleadosService);
  private readonly hueco = viewChild.required('hueco', { read: ViewContainerRef });

  protected readonly empleados = signal<Empleado[]>([]);
  protected readonly cargando = signal(true);
  protected readonly error = signal('');
  protected readonly filtro = signal('');
  protected readonly elegido = signal<Empleado | undefined>(undefined);
  protected readonly segundos = signal(0);

  protected readonly visibles = computed(() => {
    const texto = this.filtro().toLowerCase();
    return this.empleados().filter((e) => e.nombre.toLowerCase().includes(texto));
  });

  constructor() {
    // Sin Zone.js: el temporizador avisa a Angular porque modifica una señal
    const reloj = setInterval(() => this.segundos.update((s) => s + 1), 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(reloj));

    this.servicio
      .listar()
      .pipe(takeUntilDestroyed())
      .subscribe({
        next: (lista) => {
          this.empleados.set(lista);
          this.cargando.set(false);
        },
        error: () => {
          this.error.set('No se pudo cargar la lista');
          this.cargando.set(false);
        },
      });
  }

  protected seleccionar(e: Empleado): void {
    this.elegido.set(e);
    this.hueco().clear();
    const ref = this.hueco().createComponent(AvisoComponent);
    ref.setInput('texto', 'Has elegido a ' + e.nombre);
  }
}
<!-- src/app/lista-empleados/lista-empleados.component.html -->
<h2>Empleados</h2>
<input [(ngModel)]="filtro" placeholder="Filtrar por nombre" />
<span class="reloj">En pantalla desde hace {{ segundos() }} s</span>

@if (error()) {
  <p>{{ error() }}</p>
}
@if (cargando()) {
  <p>Cargando...</p>
} @else {
  <ul>
    @for (e of visibles(); track e.empId) {
      <li [class.par]="$even">
        <app-tarjeta-empleado [empleado]="e" titulo="Ficha" (elegido)="seleccionar($event)" />
      </li>
    } @empty {
      <li>Nadie coincide con «{{ filtro() }}»</li>
    }
  </ul>
}

<div>
  @switch (elegido()?.deptId) {
    @case (10) {
      <p>Dirección</p>
    }
    @case (undefined) {
      <p>Pulsa una tarjeta</p>
    }
    @default {
      <p>Departamento {{ elegido()?.deptId }}</p>
    }
  }
</div>
<p [style.color]="elegido() ? '#283593' : '#90a4ae'">Comisión: {{ elegido()?.comision ?? 'sin datos' }}</p>
<ng-container #hueco />

Las decisiones que no podía tomar un generador:

  • Estado en señales. Cada campo que pinta la plantilla pasa a signal. El getter visibles pasa a computed, que solo se recalcula al cambiar filtro o empleados.
  • [(ngModel)] con una señal. El enlace doble acepta una señal escribible: ngModel lee filtro() y escribe con filtro.set().
  • Ciclo de vida. ngOnInit y ngOnDestroy desaparecen: la carga se hace en el constructor, y la limpieza con DestroyRef y takeUntilDestroyed (módulo 15).
  • track e.empId y $even en lugar del método porId y del cálculo con el índice.
  • ngStyle a [style.color], lo que ngstyle-to-style no llegó a hacer.
  • data-titulo="Ficha" a titulo="Ficha". El atributo con data- nunca había llegado a la entrada, y la tarjeta mostraba «Empleado». Ahora, al quitar el prefijo, se ve «Ficha» por primera vez. Es otro fallo antiguo que aflora al modernizar.

La tarjeta pierde @HostBinding y ngOnChanges:

// src/app/tarjeta-empleado/tarjeta-empleado.component.ts
import { Component, computed, input, output } from '@angular/core';

import { Empleado } from '../api';
import { ResaltarDirective } from '../resaltar.directive';
import { SalarioPipe } from '../salario.pipe';

@Component({
  selector: 'app-tarjeta-empleado',
  templateUrl: './tarjeta-empleado.component.html',
  styleUrl: './tarjeta-empleado.component.css',
  imports: [ResaltarDirective, SalarioPipe],
  host: { '[class.directivo]': 'directivo()' },
})
export class TarjetaEmpleadoComponent {
  readonly empleado = input.required<Empleado>();
  readonly titulo = input('Empleado');
  readonly elegido = output<Empleado>();

  protected readonly directivo = computed(() => this.empleado().salario >= 4000);
}

La directiva, @HostListener:

// src/app/resaltar.directive.ts
import { Directive, ElementRef, Renderer2, inject, input } from '@angular/core';

@Directive({
  selector: '[appResaltar]',
  host: {
    '(mouseenter)': 'entrar()',
    '(mouseleave)': 'salir()',
  },
})
export class ResaltarDirective {
  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef);
  private readonly renderer = inject(Renderer2);

  readonly color = input('#e8eaf6', { alias: 'appResaltar' });

  protected entrar() {
    this.renderer.setStyle(this.el.nativeElement, 'background', this.color());
  }

  protected salir() {
    this.renderer.removeStyle(this.el.nativeElement, 'background');
  }
}

Las rutas pasan de módulos a ficheros de rutas, y la guarda de clase, a función:

// src/app/app.routes.ts
import { Routes } from '@angular/router';

import { sesionGuard } from './sesion.guard';

export const rutas: Routes = [
  { path: '', redirectTo: 'empleados', pathMatch: 'full' },
  {
    path: 'empleados',
    loadComponent: () =>
      import('./lista-empleados/lista-empleados.component').then((m) => m.ListaEmpleadosComponent),
  },
  {
    path: 'departamentos',
    canActivate: [sesionGuard],
    loadChildren: () => import('./departamentos/departamentos.routes').then((m) => m.rutasDepartamentos),
  },
];
// src/app/departamentos/departamentos.routes.ts
import { Routes } from '@angular/router';

export const rutasDepartamentos: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./lista-departamentos.component').then((m) => m.ListaDepartamentosComponent),
  },
];
// src/app/sesion.guard.ts
import { CanActivateFn } from '@angular/router';

export const sesionGuard: CanActivateFn = () => true;

Con esto, app-routing.module.ts y departamentos.module.ts se borran, y desaparece también el error que había dejado route-lazy-loading.


14. Sin Eager y sin Zone.js

Ahora sí: todo lo que pinta la vista avisa a Angular por sí mismo, con señales, entradas y eventos de plantilla. Podemos dar los dos últimos pasos.

1. Quitar changeDetection: ChangeDetectionStrategy.Eager de todos los componentes. Ahora son OnPush, el valor por defecto de la 22.

2. Quitar Zone.js. El arranque se queda en lo mínimo:

// src/main.ts
import { provideHttpClient } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';

import { AppComponent } from './app/app.component';
import { rutas } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [provideRouter(rutas), provideHttpClient()],
}).catch((err) => console.error(err));

Sin provideZoneChangeDetection(), la aplicación ya es zoneless. Para que Zone.js ni siquiera se descargue, se borra de polyfills en angular.json (en build y, si está, en test) y se desinstala el paquete con npm uninstall zone.js.

El resultado, compilado, probado y ejecutado:

La aplicación modernizada: mismo aspecto, el reloj avanza sin Zone.js, las tarjetas muestran «Ficha» y debajo «Departamento 20» y el aviso

Figura 8. La aplicación modernizada, sin módulos, sin Zone.js y con OnPush. El reloj avanza porque modifica una señal.

Y el tamaño inicial de la aplicación, según ng build:

Paso Initial total (sin comprimir) Transferencia estimada
Angular 21, clásica 346,96 kB 93,63 kB
Angular 22 tras ng update y los arreglos 370,05 kB 99,91 kB
Angular 22 modernizada, sin Zone.js 266,38 kB 71,97 kB

Desaparece el fichero polyfills entero, y la lista de empleados pasa a cargarse de forma perezosa. Es una aplicación pequeña, así que no saques conclusiones de los números exactos, pero la dirección es la que encontrarás en proyectos reales.

⚠️ Importante: En un proyecto real, quita Zone.js solo cuando hayas retirado Eager de todos los componentes y la aplicación funcione bien así. Si quedan componentes con Eager que dependen de que Zone.js dispare la detección, se congelarán como en la figura 6. El módulo 09 explica cómo detectar esos casos con provideCheckNoChangesConfig.


15. Estrategia para un proyecto real

Todo lo anterior cabe en una tarde en una aplicación de diez ficheros. En una de quinientos, el orden y la disciplina importan más que las herramientas.

Fase Qué haces Cuándo la das por buena
0. Preparar Última versión de tu mayor, dependencias al día, pruebas en verde ng build y ng test sin errores
1. Actualizar ng update ... --create-commits en una rama Compila, pasan las pruebas y la aplicación se comporta igual
2. Arreglar Lo eliminado sin migración, de arriba abajo Sin errores ni avisos nuevos
3. Publicar Desplegar la versión actualizada sin modernizar Una semana sin incidencias
4. Modernizar Un generador y una carpeta cada vez, con --path Cada commit compila
5. Señales Estado a señales, pantalla a pantalla Pruebas de esa pantalla en verde
6. Retirar protecciones Eager, withXhr, los diagnósticos silenciados… Sin regresiones
7. Zone.js fuera Cuando no quede ningún Eager necesario Pruebas E2E en verde

La fase 3 es la que más se olvida y la que más problemas evita: si publicas la actualización y la modernización juntas y algo falla, no sabrás si ha sido Angular 22 o tu código nuevo.

Si te quedas en la LTS

Quedarse en Angular 21 hasta junio de 2027 es una decisión razonable para un proyecto en producción. No impide avanzar: casi toda la modernización funciona igual en la 21. Los generadores control-flow, standalone, inject, signal-input, outputs, signal-queries, route-lazy-loading, cleanup-unused-imports, self-closing-tag, ngclass-to-class y ngstyle-to-style existen en la LTS; la excepción es service, de la 22.1. Si además sustituyes ya lo que la 22 elimina (sección 10), el día que actualices ng update tendrá muy poco que hacer.


16. Errores frecuentes

Síntoma Causa Solución
ng update se niega a empezar Cambios sin confirmar Commit o git stash; evita --allow-dirty
Conflicto de dependencias al instalar Una biblioteca aún no admite la 22 Actualízala antes, o espera; --force solo si sabes por qué
Errores de TypeScript tras actualizar, en código que no es de Angular TypeScript 6 Módulo 02
NG6008: Component X is standalone, and cannot be declared in an NgModule Falta standalone: false Añádelo, o mejor, impórtalo en lugar de declararlo
NG8001: 'app-x' is not a known element El módulo o el componente no importa app-x Declarar o importar; no uses CUSTOM_ELEMENTS_SCHEMA para callarlo
NG8103 sobre *ngIf o *ngFor, y la directiva no hace nada Componente standalone sin NgIf/CommonModule Importarlo, o mejor, ng g @angular/core:control-flow
TS2305: ... no exported member 'ComponentFactoryResolver' Eliminado en 22 viewContainerRef.createComponent(Clase)
TS2554: Expected 2 arguments, but got 3 en canMatch La migración cambió la llamada pero no tu método Declara el tercer parámetro PartialMatchRouteSnapshot
La vista se queda congelada, sin errores Componente OnPush con estado en campos normales Señales; mientras tanto, Eager
Un texto que dependía de null cambia tras quitar $safeNavigationMigration ?. da undefined en la 22 Revisa cada comparación estricta
Una entrada deja de recibir su valor, sin errores data-[entrada], que la 22 ya no enlaza Quita el prefijo data-
Tras un generador, el proyecto ya no compila Fallo del generador git show HEAD, arregla a mano o git revert HEAD

17. Ejercicios

Ejercicio 1: Datar un proyecto

Te dan un proyecto con este main.ts y este fragmento de angular.json. ¿Qué indicios tienes de su época, y qué orden de trabajo propondrías?

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule);
"builder": "@angular-devkit/build-angular:browser",
"polyfills": ["zone.js"],
"test": { "builder": "@angular-devkit/build-angular:karma" }
Ver solución
  • platformBrowserDynamic y bootstrapModule: arranque con módulos y el paquete platform-browser-dynamic, propio de proyectos antiguos.
  • @angular-devkit/build-angular:browser: el builder de Webpack, obsoleto en la 22.
  • zone.js en polyfills: detección de cambios con zonas.
  • Karma: el ejecutor de pruebas antiguo.

Pero ninguno de estos indicios da la versión: hay que mirar package.json o ejecutar ng version. El orden sería:

  1. ng version para saber de dónde partes, y actualizar de versión mayor en versión mayor hasta la 22, compilando y probando en cada salto.
  2. Ya en la 22, las migraciones opcionales de la CLI, cada una en su commit: ng update @angular/cli --name use-application-builder y ng update @angular/cli --name migrate-karma-to-vitest.
  3. Publicar sin modernizar.
  4. Modernizar por capas (sección 12) y, al final, quitar Zone.js (sección 14).

Ejercicio 2: De directivas estructurales a flujo de control

Traduce a mano esta plantilla al flujo de control moderno. Después, compara con lo que haría ng generate @angular/core:control-flow.

<p *ngIf="cargando; else lista">Cargando...</p>
<ng-template #lista>
  <ul>
    <li *ngFor="let e of visibles; let i = index; trackBy: porId" [ngClass]="{ par: i % 2 === 0 }">
      {{ e.nombre }}
    </li>
  </ul>
</ng-template>
Ver solución
@if (cargando) {
  <p>Cargando...</p>
} @else {
  <ul>
    @for (e of visibles; track e.empId) {
      <li [class.par]="$even">{{ e.nombre }}</li>
    } @empty {
      <li>No hay empleados</li>
    }
  </ul>
}

El generador, sobre esta misma plantilla, produjo @for (e of visibles; track porId(i, e); let i = $index) y mantuvo [ngClass]. Es correcto y seguro, pero literal: reutiliza el método porId y el índice. A mano puedes usar track e.empId, que dice lo mismo sin un método, y $even en lugar de calcular i % 2 === 0. @empty no tiene equivalente directo en la sintaxis antigua, así que el generador nunca lo añade.

Ejercicio 3: Decoradores a señales

Pasa este componente a la API de señales, sin @Input, @Output, @HostBinding ni ngOnChanges:

@Component({ selector: 'app-tarjeta-empleado', standalone: false, templateUrl: './tarjeta-empleado.component.html' })
export class TarjetaEmpleadoComponent implements OnChanges {
  @Input() empleado!: Empleado;
  @Input() titulo = 'Empleado';
  @Output() elegido = new EventEmitter<Empleado>();

  @HostBinding('class.directivo') directivo = false;

  ngOnChanges(cambios: SimpleChanges): void {
    if (cambios['empleado']) {
      this.directivo = this.empleado.salario >= 4000;
    }
  }
}
Ver solución
@Component({
  selector: 'app-tarjeta-empleado',
  templateUrl: './tarjeta-empleado.component.html',
  imports: [ResaltarDirective, SalarioPipe],
  host: { '[class.directivo]': 'directivo()' },
})
export class TarjetaEmpleadoComponent {
  readonly empleado = input.required<Empleado>();
  readonly titulo = input('Empleado');
  readonly elegido = output<Empleado>();

  protected readonly directivo = computed(() => this.empleado().salario >= 4000);
}

En la plantilla, cada lectura pasa a ser una llamada: empleado().nombre, titulo(), elegido.emit(empleado()).

  • @Input() empleado! a input.required: el ! solo callaba a TypeScript; required hace que el compilador exija la entrada en cada uso.
  • ngOnChanges desaparece: directivo es un valor derivado de empleado, así que es un computed, sin cadenas como cambios['empleado'].
  • @HostBinding pasa a la propiedad host de los metadatos, leyendo la señal.
  • Ya no hace falta standalone: false, pero sí declarar en imports lo que use la plantilla.

Los generadores signal-input y outputs hacen las tres primeras líneas y actualizan las lecturas; host y computed son a mano.

Ejercicio 4: Leer un error de módulos

Al compilar una aplicación con módulos aparece esto. ¿Qué ha pasado y cuáles son las dos formas de arreglarlo?

[ERROR] NG6008: Component SinMarcaComponent is standalone, and cannot be declared in an NgModule. Did you mean to import it instead?
Ver solución

SinMarcaComponent no lleva standalone: false, así que es standalone (el valor por defecto actual) y no puede estar en las declarations de un módulo. Suele pasar al crear un componente con ng generate dentro de un proyecto antiguo, o al copiar código moderno a uno clásico.

  • Opción 1, seguir con módulos: añadir standalone: false al decorador. El componente pertenece entonces a ese módulo.
  • Opción 2, la recomendada: sacarlo de declarations y ponerlo en imports del módulo, como sugiere el propio mensaje. Un componente standalone puede importarse en un módulo, y así no añades más código antiguo.

Ejercicio 5: Sin ComponentFactoryResolver

Este código compilaba en la 21 y no compila en la 22. Reescríbelo:

constructor(private resolver: ComponentFactoryResolver) {}

mostrar(texto: string) {
  const fabrica = this.resolver.resolveComponentFactory(AvisoComponent);
  this.hueco.clear();
  const ref = this.hueco.createComponent(fabrica);
  ref.instance.texto = texto;
}
Ver solución
mostrar(texto: string) {
  this.hueco.clear();
  const ref = this.hueco.createComponent(AvisoComponent);
  ref.setInput('texto', texto);
}

createComponent recibe directamente la clase del componente, y el constructor ya no necesita ComponentFactoryResolver. Cambiar la asignación por ref.setInput no es obligatorio para compilar, pero es lo correcto: notifica el cambio de entrada a Angular. Si AvisoComponent usa input(), ni siquiera habría alternativa, porque una entrada de señal no se puede asignar desde fuera. Este cambio también funciona en la 21, así que puedes hacerlo antes de actualizar.

Ejercicio 6: La guarda que dejó de compilar

Tras ng update, este fichero da TS2554: Expected 2 arguments, but got 3. Corrígelo de forma que no quede ningún any:

export class SoloAdmin implements CanMatch {
  canMatch(ruta: Route, segmentos: UrlSegment[]): boolean {
    return segmentos.length > 0 && ruta.path === 'admin';
  }
}

export function comprobar(guardia: SoloAdmin, ruta: Route, segmentos: UrlSegment[]) {
  return guardia.canMatch(ruta, segmentos, {} as any /* added by migration */);
}
Ver solución
import { CanMatch, PartialMatchRouteSnapshot, Route, UrlSegment } from '@angular/router';

export class SoloAdmin implements CanMatch {
  canMatch(ruta: Route, segmentos: UrlSegment[], actual: PartialMatchRouteSnapshot): boolean {
    return segmentos.length > 0 && ruta.path === 'admin';
  }
}

export function comprobar(guardia: SoloAdmin, ruta: Route, segmentos: UrlSegment[], actual: PartialMatchRouteSnapshot) {
  return guardia.canMatch(ruta, segmentos, actual);
}

La migración can-match-snapshot-required arregla las llamadas, añadiendo un relleno, pero no las declaraciones. Como el método de la clase solo aceptaba dos parámetros, la llamada con tres no compila. La solución es declarar el tercer parámetro y que quien llama pase el valor real, que normalmente le llega a él a su vez. Mejor todavía: convertir la guarda en una función CanMatchFn, a la que es el router quien le pasa los argumentos.

Ejercicio 7: La lista que no carga

Tras quitar changeDetection: ChangeDetectionStrategy.Eager de este componente, la lista se queda en «Cargando…» para siempre, sin errores. Explica por qué y arréglalo sin volver a poner Eager.

export class ListaEmpleadosComponent implements OnInit {
  private servicio = inject(EmpleadosService);
  empleados: Empleado[] = [];
  cargando = true;

  ngOnInit(): void {
    this.servicio.listar().subscribe((lista) => {
      this.empleados = lista;
      this.cargando = false;
    });
  }
}
Ver solución

Sin changeDetection, el componente es OnPush en Angular 22. Un componente OnPush solo se revisa si algo lo marca: una entrada nueva, un evento de su plantilla, una señal que lee o markForCheck(). Asignar un campo dentro de subscribe no marca nada. Aunque Zone.js dispare la detección de cambios al llegar la respuesta, Angular se salta este componente.

export class ListaEmpleadosComponent {
  private readonly servicio = inject(EmpleadosService);
  protected readonly empleados = signal<Empleado[]>([]);
  protected readonly cargando = signal(true);

  constructor() {
    this.servicio
      .listar()
      .pipe(takeUntilDestroyed())
      .subscribe((lista) => {
        this.empleados.set(lista);
        this.cargando.set(false);
      });
  }
}

En la plantilla, empleados() y cargando(). Al escribir en una señal que lee la plantilla, Angular marca el componente y lo vuelve a pintar, con o sin Zone.js. Otra opción igual de válida es toSignal(this.servicio.listar()) o un httpResource (módulo 13), que ya traen el estado de carga.

Ejercicio 8: Revisar un $safeNavigationMigration

Tras ng update, encuentras esto en una plantilla. ¿Qué mostraba la aplicación en la 21 antes de elegir un empleado? ¿Qué mostrará si quitas el envoltorio?

<div [ngSwitch]="$safeNavigationMigration(elegido?.deptId)">
  <p *ngSwitchCase="10">Dirección</p>
  <p *ngSwitchCase="undefined">Pulsa una tarjeta</p>
  <p *ngSwitchDefault>Departamento {{ elegido?.deptId }}</p>
</div>
Ver solución

En la 21, elegido?.deptId daba null sin empleado elegido. ngSwitchCase compara con ===, y null === undefined es falso, así que se mostraba el caso por defecto: «Departamento», con el número vacío. La migración conserva ese resultado con el envoltorio.

Sin el envoltorio, en la 22 elegido?.deptId da undefined, coincide con el segundo caso y se muestra «Pulsa una tarjeta». Lo comprobamos en la aplicación de este módulo: es lo que el autor quería, así que quitar el envoltorio arregla un fallo antiguo. En otros casos podría ser al revés; por eso cada envoltorio se revisa por separado.

Ejercicio 9: Buscar lo que la 22 rompe en silencio

Escribe órdenes de búsqueda (con git grep o el buscador de tu editor) para localizar, antes de actualizar, dos cambios de la 22 que no tienen migración ni dan error: el prefijo data- en enlaces y las rutas hijas que dependen de no heredar parámetros.

Ver solución
# Enlaces con prefijo data-: data-[entrada], data-(evento), data-bind-...
git grep -nE 'data-(\[|\(|bind-|on-)' -- 'src/**/*.html' 'src/**/*.ts'

# Rutas hijas: revisar cada children: con path propio y parámetros
git grep -n 'children:' -- 'src/**/*.ts'

La primera búsqueda encuentra las formas de enlace con prefijo data-. Un atributo data-titulo="..." sin corchetes no le afecta: nunca enlazó una entrada, ni en la 21 ni en la 22.

La segunda no da la respuesta directamente: hay que mirar cada ruta con hijos y ver si alguna hija con path propio lee un parámetro con el mismo nombre que el padre. En la 22 lo heredará, y en la 21 no. Si quieres el comportamiento antiguo mientras lo revisas, configura withRouterConfig({ paramsInheritanceStrategy: 'emptyOnly' }).

Ejercicio 10: Plan para quitar Zone.js

Tienes una aplicación ya en Angular 22, con todos los componentes en Eager tras ng update y provideZoneChangeDetection() en los proveedores. Escribe la lista de pasos para quitar Zone.js y cómo comprobarás cada uno.

Ver solución
  1. Buscar dependencias directas de zonas: NgZone.onStable, onMicrotaskEmpty, isStable, onUnstable. Hay que sustituirlas por afterNextRender o afterEveryRender. NgZone.run y runOutsideAngular pueden quedarse.
  2. Pasar el estado a señales, componente a componente, y quitar Eager de cada uno al terminarlo. Comprobación: la pantalla se actualiza igual y sus pruebas pasan. Con provideCheckNoChangesConfig en desarrollo detectarás los cambios que no se notifican.
  3. Cuando no quede ningún Eager necesario, quitar provideZoneChangeDetection(). Comprobación: pruebas de extremo a extremo.
  4. Quitar zone.js y zone.js/testing de polyfills en angular.json, en build y en test, y ejecutar npm uninstall zone.js. Comprobación: ng build ya no genera el fichero polyfills, y ng test pasa.
  5. Pruebas con fakeAsync: dependen de Zone.js y hay que pasarlas a los temporizadores de Vitest (módulo 26).

En la aplicación de este módulo, los pasos 2 a 4 bastaron, y el tamaño inicial bajó de 370 kB a 266 kB.

Ejercicio 11 (propuesto): Tu propia actualización

Crea un proyecto con npx @angular/cli@21 new prueba21, añádele un componente con un ?. comparado con === en la plantilla, un provideHttpClient() y un canMatch llamado a mano, y confírmalo en Git. Actualízalo con ng update @angular/core@22 @angular/cli@22 --create-commits y, para cada commit de migración, escribe en una línea qué conserva y cuándo lo retirarías.

Ejercicio 12 (propuesto): Modernizar por carpetas

Toma la aplicación clásica de este módulo (o cualquier proyecto con módulos) y moderniza solo una carpeta con --path, por ejemplo ng generate @angular/core:control-flow --path src/app/departamentos. Comprueba que el resto del proyecto no cambia y que todo sigue compilando. ¿Qué generadores admiten trabajar así sin dejar el proyecto a medias? (Pista: no todos aceptan --path; ng generate @angular/core:<nombre> --help lo dice.)


Resumen

Concepto API Idea clave
Módulo @NgModule, declarations, imports, exports Lo importado vale para todas sus declaraciones
Declaración clásica standalone: false Sin ella, NG6008 al declararla en un módulo
Rutas con módulos RouterModule.forRoot, forChild, loadChildren Equivalen a provideRouter y a ficheros de rutas
Directivas estructurales *ngIf, *ngFor, ngSwitch Azúcar sobre <ng-template>; obsoletas desde la 20
Decoradores @Input, @Output, @ViewChild, @HostBinding input(), output(), viewChild(), host
Zone.js provideZoneChangeDetection, NgZone Avisa al acabar cada tarea; desde la 21, zoneless por defecto
Actualizar ng update @angular/core@22 @angular/cli@22 -C Una mayor cada vez, con un commit por migración
Migraciones de 22 change-detection-eager, http-xhr-backend, safe-optional-chaining… Conservan el comportamiento de la 21
Eliminado en 22 ComponentFactoryResolver, provideRoutes, checkNoChanges, Hammer Sin migración: lo señala ng build
Cambios silenciosos paramsInheritanceStrategy, data-[x] Búscalos antes de actualizar
Retirar protecciones Eager, withXhr(), $safeNavigationMigration Una a una; Eager exige estado en señales
Modernizar ng generate @angular/core:control-flow, standalone, inject, signals… Un generador por commit y compilar tras cada uno
@Service ng generate @angular/core:service Desde la 22.1; tras inject
Sin Zone.js Quitar polyfills y el proveedor Solo cuando nada dependa de Eager