Control flow en Angular: guía completa para dejar atrás ngIf y ngFor

Durante años, escribir una condición en una plantilla de Angular implicaba importar CommonModule (o NgIf y NgFor a mano en los componentes standalone), acordarse del asterisco y, si había rama alternativa, declarar un <ng-template> con su referencia. Todo eso ha pasado a segundo plano. Desde Angular 17 las plantillas cuentan con una sintaxis de bloques integrada en el compilador: @if, @for, @switch y @defer, a las que se suma @let desde la versión 19. Con Angular 20, las directivas NgIf, NgFor y NgSwitch quedaron marcadas como obsoletas.

Este artículo recorre cada bloque con ejemplos completos, explica qué cambia por dentro, cuándo conviene usar cada opción y cómo migrar un proyecto existente con el schematic oficial. Los ejemplos están contrastados con la documentación de Angular v22, la versión estable vigente. Si necesitas repasar los fundamentos del framework (decoradores, inyección de dependencias, HttpClient y señales), tienes la guía TypeScript con Angular.

Cronología: de la vista previa a la obsolescencia

El control flow integrado llegó en Angular 17 (noviembre de 2023) como vista previa para desarrolladores y se declaró estable en Angular 18 (22 de mayo de 2024). @let se estabilizó en Angular 19, publicado el 19 de noviembre de 2024, según el registro de cambios del framework. La hidratación incremental, que reutiliza la sintaxis de @defer con triggers hydrate, pasó a estable en Angular 20 y en la documentación actual figura activada por defecto al usar provideClientHydration().

El paso definitivo llegó con Angular 20.0 (28 de mayo de 2025): el changelog incluye entre las deprecaciones de core que «ngIf/ngFor/ngSwitch are deprecated». La referencia de la API de NgIf indica que hay que usar el bloque @if y que la intención es eliminarlo «en una futura versión mayor». No hay fecha de eliminación publicada, así que los proyectos antiguos siguen compilando, pero el código nuevo debería escribirse ya con bloques.

Qué cambia por dentro y por qué importa

Las directivas estructurales son clases que se instancian en tiempo de ejecución, reciben un TemplateRef y un ViewContainerRef y crean o destruyen vistas. Los bloques de control flow, en cambio, los entiende el compilador, que genera instrucciones directas para el motor de renderizado. Las consecuencias prácticas son estas:

  • Sin importaciones. Los bloques están disponibles en cualquier plantilla. Desaparecen CommonModule, NgIf, NgFor, NgSwitch, NgSwitchCase y NgSwitchDefault del array imports.
  • Menos JavaScript en el bundle, porque esas directivas dejan de formar parte de la aplicación.
  • Sintaxis más legible. Una rama else deja de ser una referencia a una plantilla situada en otra parte del fichero.
  • Reutilización de vistas en @for, con una diferencia de comportamiento respecto a *ngFor que se explica más abajo y que conviene conocer antes de migrar.
  • Optimizaciones específicas para arrays: la documentación indica que la colección puede ser cualquier iterable de JavaScript, pero que Angular aplica mejoras adicionales de rendimiento cuando es un array.

@if, @else if y @else

La condición va entre paréntesis y el contenido entre llaves. Se admiten tantos @else if como hagan falta y un único @else al final. Esta es la versión clásica con rama alternativa y su equivalente actual:

<!-- Antes -->
<div *ngIf="usuario; else invitado">
  Hola, {{ usuario.nombre }}
</div>
<ng-template #invitado>
  <a routerLink="/login">Iniciar sesión</a>
</ng-template>

<!-- Ahora -->
@if (usuario) {
  <div>Hola, {{ usuario.nombre }}</div>
} @else {
  <a routerLink="/login">Iniciar sesión</a>
}

Las cadenas de condiciones también ganan claridad. Con directivas había que anidar o repetir *ngIf con expresiones excluyentes; con bloques se escribe como en cualquier lenguaje:

@if (pedido.estado === 'pagado') {
  <app-factura [pedido]="pedido" />
} @else if (pedido.estado === 'pendiente') {
  <app-boton-pago [pedido]="pedido" />
} @else {
  <p>Pedido cancelado.</p>
}

Guardar el resultado con «as»

Cuando la expresión es larga o devuelve un valor que quieres reutilizar dentro del bloque, as lo guarda en un alias. Es el sustituto directo del patrón *ngIf="obs$ | async as datos":

@if (usuario$ | async; as usuario) {
  <h2>{{ usuario.nombre }}</h2>
  <p>{{ usuario.email }}</p>
} @else {
  <p>Cargando perfil…</p>
}

Fíjate en el punto y coma antes de as: forma parte de la sintaxis del bloque. Con señales el patrón es más corto, porque basta con leer la señal en la condición:

import { Component, signal } from '@angular/core';

interface Usuario {
  nombre: string;
  email: string;
}

@Component({
  selector: 'app-perfil',
  template: `
    @if (usuario(); as u) {
      <h2>{{ u.nombre }}</h2>
      <p>{{ u.email }}</p>
    } @else {
      <p>No hay sesión iniciada.</p>
    }
  `,
})
export class PerfilComponent {
  usuario = signal<Usuario | null>(null);
}

@for: track obligatorio, variables de contexto y @empty

La sintaxis básica es @for (elemento of coleccion; track expresion). A diferencia de *ngFor, donde trackBy era opcional y muchos proyectos lo omitían, aquí la expresión track es obligatoria. El compilador falla si no la pones, y eso evita de raíz el error clásico de reconstruir todo el DOM de una lista cada vez que cambia la referencia del array.

import { Component, signal } from '@angular/core';

interface Producto {
  id: number;
  nombre: string;
  precio: number;
}

@Component({
  selector: 'app-catalogo',
  template: `
    <ul>
      @for (p of productos(); track p.id) {
        <li>{{ p.nombre }}: {{ p.precio }} €</li>
      } @empty {
        <li>No hay productos disponibles.</li>
      }
    </ul>
  `,
})
export class CatalogoComponent {
  productos = signal<Producto[]>([
    { id: 1, nombre: 'Teclado mecánico', precio: 89 },
    { id: 2, nombre: 'Monitor 27"', precio: 249 },
  ]);
}

Qué poner en track

La documentación establece tres opciones, de más a menos recomendable:

  • Un identificador único (id, uuid…). Es la opción preferida. Si los datos no lo tienen, conviene añadirlo.
  • $index, solo para colecciones estáticas que no cambian nunca. En listas que se reordenan o se filtran produce actualizaciones innecesarias.
  • La referencia del propio elemento (track item), como último recurso. Angular no puede asociar los datos con los nodos del DOM de forma estable y las actualizaciones pueden volverse notablemente más lentas.

Variables de contexto

Dentro de un @for están siempre disponibles $count, $index, $first, $last, $even y $odd. Si necesitas otro nombre, por ejemplo para leer las variables del bucle externo desde uno anidado, se declara un alias con let:

<table>
  @for (fila of filas; track fila.id; let i = $index, par = $even) {
    <tr [class.zebra]="par">
      <td>{{ i + 1 }} / {{ filas.length }}</td>
      @for (celda of fila.celdas; track celda.id; let j = $index) {
        <td>Fila {{ i }}, columna {{ j }}: {{ celda.valor }}</td>
      }
    </tr>
  }
</table>

El bloque @for no admite continue ni break. Si necesitas filtrar, hazlo antes, en una propiedad computed() que devuelva la lista ya filtrada.

Una diferencia de comportamiento al migrar

Aquí está el único cambio que puede alterar el resultado de una aplicación existente. Con *ngFor, si la función trackBy devolvía un valor distinto para un elemento, Angular destruía y recreaba el elemento del DOM. Con @for, el framework prioriza la reutilización de vistas: si la propiedad usada en track cambia pero la referencia del objeto es la misma, se actualizan los bindings de la vista existente, incluidos los inputs de los componentes hijos, en lugar de recrearla. La propia documentación de la migración lo señala como un cambio a tener en cuenta. Revisa los componentes cuyo estado dependa de que se reinicien al cambiar el elemento.

@switch: sin fallthrough y con comprobación de exhaustividad

El bloque @switch compara con el operador de igualdad estricta (===), no tiene fallthrough (no hacen falta break ni return) y permite agrupar varios @case consecutivos sobre un mismo contenido. @default es opcional; si ninguna rama coincide y no hay default, no se renderiza nada.

@switch (rol) {
  @case ('admin') {
    <app-panel-admin />
  }
  @case ('revisor')
  @case ('editor') {
    <app-panel-editor />
  }
  @default {
    <app-panel-lector />
  }
}

La documentación actual añade una comprobación que no existía con ngSwitch: escribir @default never; hace que el verificador de tipos de plantillas compruebe en compilación que se han cubierto todos los miembros de un tipo unión. Si más adelante alguien añade un valor nuevo al tipo y se olvida del @case correspondiente, el compilador lo marca como error.

import { Component } from '@angular/core';

@Component({
  selector: 'app-sesion',
  template: `
    @switch (estado) {
      @case ('desconectado') {
        <button>Entrar</button>
      }
      @case ('conectado') {
        <p>Bienvenido de nuevo</p>
      }
      @case ('cargando') {
        <p>Comprobando sesión…</p>
      }
      @default never;
    }
  `,
})
export class SesionComponent {
  estado: 'desconectado' | 'cargando' | 'conectado' = 'desconectado';
}

La exhaustividad se apoya en el estrechamiento de tipos de TypeScript, que solo funciona sobre variables. No sirve con una llamada a función o con la lectura de una señal directamente en el @switch (por ejemplo @switch (estado())). La solución que propone la documentación es asignar antes el valor a una variable con @let, que es justo el siguiente bloque.

@let: variables locales en la plantilla

Con @let se declara una variable de solo lectura dentro de la plantilla, con la forma @let nombre = expresion;. La expresión puede ocupar varias líneas y se mantiene sincronizada con sus dependencias, igual que un binding.

@let usuario = usuario$ | async;
@let saludo = 'Hola, ' + usuario?.nombre;

@if (usuario) {
  <h1>{{ saludo }}</h1>
  <p>Último acceso: {{ usuario.ultimoAcceso | date: 'dd/MM/yyyy' }}</p>
}

Hay tres reglas que conviene recordar:

  • Es de solo lectura: no se puede reasignar, por ejemplo desde un manejador de evento.
  • Su ámbito es la vista actual y sus descendientes. Los bloques de control flow, los @defer y los límites de componente crean vistas nuevas, y las declaraciones no se elevan: una variable declarada dentro de un @if no existe fuera de él.
  • Solo está disponible después de su declaración.

Es especialmente útil para evitar suscribirse varias veces al mismo observable con async y para dar un nombre legible a una expresión que se repite en la plantilla.

@defer: carga diferida de partes de la vista

Este bloque no es una sustitución de una directiva, sino una capacidad nueva. El contenido de un @defer se extrae a un fichero JavaScript separado (Angular genera un import() dinámico por cada dependencia diferida) y se descarga cuando se cumple una condición. Por defecto, el disparador es on idle.

@defer (on viewport) {
  <app-comentarios [articuloId]="articulo.id" />
} @placeholder (minimum 500ms) {
  <div class="skeleton">Comentarios</div>
} @loading (after 100ms; minimum 1s) {
  <app-spinner />
} @error {
  <p>No se han podido cargar los comentarios.</p>
}

Los sub-bloques

  • @placeholder: lo que se ve antes de que empiece la carga. Con minimum se fija un tiempo mínimo de visibilidad para evitar parpadeos cuando la descarga es muy rápida. Es obligatorio si usas on viewport, on interaction o on hover sin argumento, porque esos triggers observan el placeholder, que además debe tener un único elemento raíz.
  • @loading: se muestra mientras se descargan las dependencias. after retrasa su aparición y minimum fija cuánto tiempo permanece visible; ambos temporizadores empiezan cuando se dispara la carga.
  • @error: aparece si la carga falla.

Disparadores disponibles

Los triggers se escriben con on (eventos) o when (condición) y se pueden combinar separados por punto y coma, con semántica OR:

Trigger

Cuándo se dispara

on idle / on idle(500)

Cuando el navegador queda inactivo (requestIdleCallback), con timeout opcional en milisegundos. Es el valor por defecto.

on viewport

Cuando el elemento entra en pantalla (Intersection Observer). Acepta una referencia de plantilla y opciones como rootMargin y threshold.

on interaction

Con un click o keydown sobre el placeholder o el elemento indicado.

on hover

Con mouseover o focusin.

on immediate

En cuanto termina de renderizarse el contenido no diferido.

on timer(500ms)

Tras el tiempo indicado, en ms o s.

when condicion

Cuando la expresión pasa a ser verdadera. Es unidireccional: si luego vuelve a ser falsa, el bloque no regresa al placeholder.

Precarga con prefetch

Con prefetch se separa el momento en que se descarga el código del momento en que se muestra el contenido. Una combinación habitual es descargar en un hueco de inactividad y renderizar solo cuando el usuario interactúa:

@defer (on interaction; prefetch on idle) {
  <app-editor-markdown />
} @placeholder {
  <button>Abrir editor</button>
}

Requisitos para que la carga diferida funcione

Las dependencias de un bloque solo se difieren si cumplen dos condiciones: ser standalone y no estar referenciadas fuera de bloques @defer en el mismo fichero (tampoco en consultas como @ViewChild). Si no se cumplen, se cargan de forma ansiosa dentro del bundle principal y el @defer no reduce nada. Otros dos detalles que suelen dar problemas:

  • Si importas el componente a través de un barrel file (index.ts), el empaquetador puede mantenerlo en el bundle principal. Importa directamente desde el fichero del componente.
  • Evita diferir contenido visible en la carga inicial con triggers como immediate, timer o viewport, porque provocan saltos de maquetación. Se recomienda además envolver los bloques en un contenedor con aria-live="polite" para que los lectores de pantalla anuncien el cambio.

Hidratación incremental con SSR

En aplicaciones con renderizado en servidor, un @defer muestra por defecto su @placeholder en el servidor y no ejecuta triggers hasta que llega al cliente. Con la hidratación incremental se puede renderizar el contenido real en el servidor y hidratarlo bajo demanda con triggers hydrate, que admiten las mismas variantes (idle, viewport, interaction, hover, immediate, timer y when) más hydrate never:

@defer (on idle; hydrate on interaction) {
  <app-resenas [productoId]="producto.id" />
} @placeholder {
  <p>Reseñas</p>
}

Según la guía oficial, basta con llamar a provideClientHydration() en los providers de la aplicación; la repetición de eventos se activa sola y ya no hace falta withEventReplay(). Quien necesite desactivar la hidratación incremental dispone de withNoIncrementalHydration().

Y las directivas estructurales, ¿han desaparecido?

No, y la aclaración importa. La deprecación afecta únicamente a NgIf, NgFor y NgSwitch (con sus directivas asociadas). El mecanismo de las directivas estructurales sigue existiendo: las que escribas tú (*appPermiso, por ejemplo) funcionan igual que antes, y ng-template, ng-container y ngTemplateOutlet siguen siendo herramientas válidas, por ejemplo para reutilizar un fragmento de plantilla en varios sitios. Lo que ha cambiado es el camino recomendado para los tres casos más frecuentes: condicionales, bucles y selección de ramas.

Migrar un proyecto con el schematic oficial

Angular incluye un schematic que reescribe las plantillas existentes. Se ejecuta con:

ng generate @angular/core:control-flow

Por defecto actúa sobre todo el workspace de Angular CLI y reformatea las plantillas que modifica. Dispone de dos opciones:

  • --path: limita la migración a un subdirectorio, útil para avanzar por módulos o carpetas.
  • --format: está activada por defecto; se desactiva para conservar el formato original de las plantillas y obtener un diff más pequeño.

Para que la migración sea limpia en un proyecto real, conviene seguir un orden:

  1. Partir de un árbol de trabajo limpio en Git, para poder revisar el diff y revertir con facilidad.
  2. Migrar por partes con --path, empezando por una carpeta de bajo riesgo, y ejecutar tests y compilación tras cada una.
  3. Revisar manualmente los @for resultantes: comprueba cómo ha quedado la expresión track de cada bucle, porque el cambio de comportamiento en la reutilización de vistas descrito antes puede afectar a componentes con estado propio.
  4. Eliminar de los imports los símbolos de CommonModule que ya no se usen (NgIf, NgFor, NgSwitch…). Los bloques no los necesitan.
  5. Comprobar las plantillas con ng-template reutilizado en varios sitios o con ngTemplateOutlet: son los casos que más conviene revisar a mano tras la conversión.

Errores frecuentes al empezar

  • Olvidar track: es un error de compilación, no una advertencia. Usa un identificador estable de los datos.
  • Usar track $index en listas dinámicas: funciona, pero pierde la ventaja de reutilizar vistas por elemento al reordenar o filtrar.
  • Esperar que @let sea mutable: es solo lectura. Para estado que cambia, señales en la clase del componente.
  • Usar @defer con componentes no standalone o referenciados fuera del bloque: el código seguirá cargándose de forma ansiosa y no habrá beneficio.
  • Pretender comprobar exhaustividad con @switch (senal()): guarda antes el valor en un @let.

Conclusión

El control flow integrado es hoy la forma estándar de escribir plantillas en Angular. Reduce el código de apoyo, hace obligatoria una buena práctica que antes era opcional (track), añade comprobaciones en tiempo de compilación y abre la puerta a la carga diferida y a la hidratación incremental con la misma sintaxis. Las directivas clásicas siguen funcionando, pero están marcadas para desaparecer en una futura versión mayor, así que cuanto antes se migre, menos trabajo acumulado habrá. Para la mayoría de proyectos, el schematic oficial resuelve la parte mecánica y deja para la revisión humana los puntos señalados en este artículo.

Imagen: Pexels / Godfrey Atima

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP