Parte II · Angular

5. Plantillas, control flow y formularios

La plantilla es la parte de Angular que más se escribe y la que menos se estudia. Se copia de un tutorial, funciona, y nadie se pregunta qué compila el framework ni por qué una lista pierde el foco al refrescarse, por qué un formulario tipado devuelve undefined donde debería haber un string o por qué un validador asíncrono deja el botón de enviar bloqueado para siempre. Este capítulo cierra esos huecos: primero la plantilla entendida como un lenguaje con reglas propias, después el control flow nativo con bloques, y por último los formularios reactivos hasta el nivel que exige una aplicación real.

CORE ANGULAR Tiempo de lectura: ~130 min Prerrequisitos: capítulos 2, 3 y 4

5.1 Qué vas a poder hacer al terminar

5.2 La plantilla como lenguaje

Una plantilla de Angular no es HTML. Se parece deliberadamente al HTML para que resulte familiar, pero es un lenguaje específico de dominio con su propia gramática, su propio sistema de tipos y sus propias restricciones. El navegador nunca ve tu plantilla: ve el DOM que Angular construye a partir de ella.

5.2.1 Cómo se compila

Como vimos en el capítulo 2, Angular compila de forma anticipada (AOT). El compilador analiza la plantilla, comprueba sus tipos contra los del componente y genera una función de plantilla con dos modos: uno de creación, que se ejecuta una sola vez, y uno de actualización, que se ejecuta en cada detección de cambios.

  plantilla ─► parser ─► type checker (strictTemplates) ─► generador de instrucciones
                                                                    │
   template: function App_Template(rf, ctx) {                       ▼
     if (rf & 1) { elementStart(0, 'p'); text(1); elementEnd(); }   // CREATE: una sola vez
     if (rf & 2) { advance(1); textInterpolate(ctx.nombre); }       // UPDATE: en CADA ciclo
   }                                              └─ solo toca el DOM si el valor cambió

5.2.2 Qué NO se puede hacer en una plantilla

Angular distingue dos cosas que se escriben parecido pero no son iguales:

 Expresión de plantillaSentencia de plantilla
Dónde aparece{{ }}, [prop]="…"(evento)="…"
Cuándo se evalúaEn cada detección de cambiosSolo cuando ocurre el evento
Asignaciones (=)No
Encadenar con ;No
Pipes (|)No
Debe ser libre de efectosObligatorioEl efecto es su razón de ser

En ninguno de los dos casos se permite:

Por qué existen estas restricciones

1. Analizabilidad. El compilador genera código a partir de la expresión y comprueba sus tipos. Si la plantilla pudiera hacer cualquier cosa, ni el type checking ni la generación de instrucciones optimizadas serían posibles.

2. Idempotencia. Las expresiones se reevalúan en cada ciclo; si pudieran asignar o crear objetos, un simple repintado cambiaría el estado de la aplicación y aparecerían bucles.

3. Testabilidad. Al no poder acceder a window ni a Math, la lógica se ve obligada a vivir en la clase, que es donde se puede probar sin renderizar nada.

4. Seguridad. Sin eval ni acceso a globales, la superficie de ataque es mucho menor y el resultado es compatible con una Content Security Policy estricta.

Analogía Piensa en la plantilla como en la fórmula de una celda de hoja de cálculo: puedes leer otras celdas y combinarlas, pero no escribir en ellas desde la fórmula. Esa limitación es justo lo que permite a la hoja recalcular en cualquier orden sin volverse loca. Angular aplica el mismo principio a la vista.

5.3 Sintaxis de binding completa

5.3.1 Interpolación y sanitización

La interpolación {{ expr }} convierte el resultado a cadena (null y undefined se convierten en cadena vacía) y lo inserta como texto, no con innerHTML. Por eso nunca puede inyectar HTML: si el valor es <img onerror="…">, se ve literalmente ese texto en pantalla.

interpolacion.component.html
<p>Hola, {{ usuario.nombre }}</p>                    <!-- texto: siempre escapado -->
<p>{{ total > 0 ? 'Con tareas' : 'Vacío' }}</p>      <!-- ternario -->
<p>{{ apodo ?? 'sin apodo' }}</p>                   <!-- coalescencia -->
<p>{{ precio * unidades | currency:'EUR' }}</p>     <!-- aritmética + pipe -->
<img src="/avatares/{{ usuario.id }}.png" alt="Avatar de {{ usuario.nombre }}">
<a [href]="enlaceDelUsuario">Perfil</a>             <!-- saneado: javascript: se neutraliza -->
Matiz importante «Interpolación segura» no significa «dato validado». El texto se escapa, sí, pero si después usas ese mismo valor en un [href] o construyes HTML a mano para un [innerHTML], vuelves a estar expuesto. La sección 5.14 desarrolla el modelo de seguridad.

5.3.2 Property binding frente a attribute binding

Este es el punto que más confusión genera, y la causa es histórica: los atributos HTML y las propiedades del DOM no son lo mismo. El atributo es lo que aparece en el marcado, su valor es siempre una cadena y sirve para inicializar. La propiedad es el estado vivo del objeto DOM y puede tener cualquier tipo. Tras la carga inicial divergen: si escribes en un <input value="a">, el atributo sigue valiendo "a" y la propiedad value vale lo que has tecleado.

Angular enlaza propiedades por defecto. [disabled]="cargando" asigna un booleano. Si escribieras disabled="{{ cargando }}" pondrías el atributo a la cadena "false", que en HTML significa… deshabilitado, porque en los atributos booleanos cuenta la presencia, no el valor.

CasoEscribePor qué
Atributos ARIA[attr.aria-label], [attr.aria-invalid]El árbol de accesibilidad se construye a partir de los atributos; es la forma canónica y compatible.
colspan/rowspan[attr.colspan] (o [colSpan])La propiedad DOM se llama colSpan; [colspan] no existe y da error de compilación.
SVG[attr.width], [attr.fill]Los elementos SVG exponen objetos SVGAnimatedLength, no valores asignables.
data-*[attr.data-id]No hay propiedad directa; dataset no se puede enlazar.
Eliminar un atributo[attr.x]="cond ? 'v' : null"Con null Angular quita el atributo en vez de ponerlo a la cadena "null".
tabla.component.htmlINCORRECTO
<td [colspan]="numColumnas">Total</td>      <!-- no existe la propiedad -->
<button disabled="{{ cargando }}">Guardar</button>  <!-- "false" deshabilita -->
<input [aria-invalid]="control.invalid">   <!-- aria-* no es propiedad -->
<div [attr.title]="tituloOpcional"></div>  <!-- queda title="null" -->
tabla.component.htmlCORRECTO
<td [attr.colspan]="numColumnas">Total</td>
<button [disabled]="cargando">Guardar</button>      <!-- booleano real -->
<input [attr.aria-invalid]="control.invalid ? 'true' : 'false'">
<div [attr.title]="tituloOpcional || null"></div>   <!-- null lo elimina -->

5.3.4 Event binding

eventos.component.html · y el manejador tipado en la clase
<button (click)="guardar()">Guardar</button>
<input (input)="onInput($event)">               <!-- $event = evento nativo -->
<input (keydown.enter)="buscar()">              <!-- Angular filtra la tecla por ti -->
<input (keydown.escape)="cancelar()">
<div (keydown.control.shift.z)="rehacer()"></div>
<div (keydown.code.keyk)="abrirPaleta()"></div>  <!-- código físico de la tecla -->
<app-task-row (eliminar)="borrar($event)" />    <!-- $event = valor emitido -->
<form [formGroup]="form" (ngSubmit)="enviar()"></form>   <!-- ngSubmit evita la recarga -->
eventos.component.ts
onInput(event: Event): void {
  const input = event.target as HTMLInputElement;   // el estrechamiento se hace en la CLASE
  this.termino.set(input.value);
}
onSoltar(event: DragEvent): void {
  event.preventDefault();          // se llama aquí, nunca en la plantilla
  this.procesar(event.dataTransfer?.files);
}
Por qué no debes meter lógica en la plantilla Es tentador escribir (click)="abierto = !abierto; contador = contador + 1; guardar()". Las sentencias de plantilla lo permiten, pero: ese código no se puede probar sin instanciar la vista, no aparece en la cobertura de tests, el comprobador de plantillas es bueno pero no idéntico al de TypeScript, y la plantilla deja de leerse como una descripción de la vista. Regla práctica: una única llamada a un método del componente, como mucho con $event como argumento.

5.3.4 Two-way binding

La sintaxis [(x)] —«banana in a box»— es azúcar sintáctico: se expande a un property binding más un event binding con la convención x / xChange.

two-way.component.html
<app-contador [(valor)]="total" />                                <!-- estas dos líneas -->
<app-contador [valor]="total" (valorChange)="total = $event" />   <!-- son lo MISMO -->
<input [(ngModel)]="nombre" name="nombre">                        <!-- ngModel / ngModelChange -->
<input [ngModel]="nombre" (ngModelChange)="nombre = $event" name="nombre">
contador.component.ts · two-way con model() (Angular 17.2+)
@Component({
  selector: 'app-contador',
  template: `<button (click)="bajar()" aria-label="Restar">-</button>
             <output>{{ valor() }}</output>
             <button (click)="subir()" aria-label="Sumar">+</button>`,
})
export class ContadorComponent {
  // model() crea a la vez la entrada 'valor' y la salida 'valorChange'. Es una
  // señal escribible: al hacer set/update, Angular emite el cambio solo.
  readonly valor = model.required<number>();
  readonly limite = model<number>(10);
  subir(): void { this.valor.update((v) => Math.min(v + 1, this.limite())); }
  bajar(): void { this.valor.update((v) => Math.max(v - 1, 0)); }
}
[(ngModel)] no se mezcla con formularios reactivos Usarlo junto a formControlName en el mismo elemento quedó obsoleto hace muchas versiones y hoy lanza un error en tiempo de ejecución. Si necesitas un ngModel dentro de un formulario reactivo para algo ajeno al modelo (un filtro visual), márcalo como [ngModelOptions]="{ standalone: true }".

5.3.5 Class y style bindings

clases.component.html
<div [class.activa]="esActiva" [class.error]="control.invalid"></div>  <!-- individual -->
<div [class]="{ activa: esActiva, error: hayError }"></div>           <!-- mapa, sin NgClass -->
<div [class]="['chip', tipo]"></div>                                  <!-- array o cadena -->
<div [style.width.px]="ancho" [style.color]="colorHex"></div>         <!-- unidad en el binding -->
<div [style.width.%]="porcentaje"></div>
<div [style]="{ width: ancho + 'px', opacity: opacidad }"></div>
<div [style.--color-acento]="tema.acento"></div>                      <!-- variable CSS -->
<div [ngClass]="{ activa: esActiva }" [ngStyle]="{ 'font-size.px': tam }"></div>

Precedencia. Cuando varias fuentes escriben sobre la misma clase o estilo, Angular aplica un orden determinista, de mayor a menor prioridad:

  MÁS ESPECÍFICO gana a MENOS ESPECÍFICO · PLANTILLA gana a DIRECTIVA, y DIRECTIVA a COMPONENTE anfitrión
  1. [class.activa] / [style.width]  en la plantilla         ← máxima prioridad
  2. [class] / [style] (mapa)        en la plantilla
  3. class="..." / style="..."       estático en la plantilla
  4. host: { '[class.activa]': ... } de una directiva
  5. host: { '[class]': ... }        de una directiva         ← aquí caen NgClass y NgStyle
  6. host: { 'class': '...' }        estático de una directiva
  7. lo mismo, en el propio componente anfitrión              ← mínima prioridad
Consecuencia práctica NgClass es una directiva, así que actúa en el nivel 5. Un [class.oculto]="true" escrito en la plantilla siempre ganará a un NgClass que intente quitar esa clase, se evalúe cuando se evalúe. Si mezclas ambos mecanismos sobre las mismas clases acabarás depurando un fantasma: elige uno por elemento. En Angular moderno, [class] y [class.x] nativos cubren el 100 % de los casos y no requieren importar nada.

5.3.6 Variables de plantilla, #ref y @let

referencias.component.html
<input #buscador placeholder="Buscar">                    <!-- #ref nativa = HTMLElement -->
<button (click)="buscar(buscador.value); buscador.focus()">Ir</button>
<app-modal #modal />                                      <!-- #ref componente = INSTANCIA -->
<button (click)="modal.abrir()">Abrir</button>
<form #f="ngForm" (ngSubmit)="enviar(f.value)"></form>    <!-- #ref="exportAs" = directiva -->
<input #ctrl="ngModel" [(ngModel)]="email" name="email" required email>
<p>{{ ctrl.invalid && ctrl.touched ? 'Email no válido' : '' }}</p>
@let usuario = usuario$ | async;                          <!-- estable desde Angular 19 -->
@let saludo = usuario ? 'Hola, ' + usuario.nombre : 'Invitado';
<h2>{{ saludo }}</h2>
<p>Tienes {{ usuario?.tareas?.length ?? 0 }} tareas</p>

5.3.7 Operadores de plantilla

OperadorQué haceNota
?.Encadenamiento seguro: si la izquierda es nula, toda la expresión devuelve undefined sin lanzar error.Idiomático para datos asíncronos que aún no han llegado.
??Coalescencia de nulos: alternativa solo si es null/undefined.No se dispara con 0 ni '', a diferencia de ||.
!Aserción de no nulo para el comprobador de tipos; no genera comprobación en ejecución.Con cuentagotas: si te equivocas, el error sale en producción.
$any(x)Escapa del type checking convirtiendo la expresión en any.Válvula para tipos de terceros mal declarados. Cada uso es deuda técnica.
|Pipe: transforma el valor. Se encadena y admite argumentos con :.Prioridad muy baja: a + b | pipe aplica el pipe a (a + b).
?. no sustituye a @if {{ pedido?.cliente?.direccion?.ciudad }} evita que la plantilla falle, pero también oculta que no hay datos: el usuario ve un hueco sin explicación. Cuando la ausencia tiene significado (cargando, sin resultados, error), modélala con @if/@else. El ?. es para lo verdaderamente opcional, no para tapar estados.

5.4 Control flow nativo

Desde Angular 17 el control de flujo vive en la sintaxis del compilador, no en directivas. Los bloques @if, @for, @switch y @defer se analizan al compilar y generan código directo, sin la maquinaria de directivas estructurales, sin NgIf/NgForOf en el bundle y sin necesidad de importar nada.

5.4.1 @if, @else if, @else

estado.component.html
@if (cargando()) {
  <app-spinner />
} @else if (error()) {
  <p role="alert" class="error">{{ error() }}</p>
} @else {
  <app-lista [tareas]="tareas()" />
}
<!-- Alias 'as': evalúa UNA vez y reutiliza. Además estrecha el tipo. -->
@if (usuario$ | async; as usuario) { <p>{{ usuario.nombre }} ({{ usuario.email }})</p> }
@if (tareaSeleccionada(); as tarea) { <app-detalle [tarea]="tarea" /> }
El alias as se basa en la veracidad del valor El bloque entra si la expresión es «verdadera». Si tu valor legítimo puede ser 0, '' o false, el bloque no se ejecutará. En ese caso escribe la condición explícita (@if (contador() !== null)) y accede al valor dentro.

5.4.2 @for y la obligación de track

lista.component.html
@for (tarea of tareas(); track tarea.id) {
  <li [class.par]="$even">
    <span class="num">{{ $index + 1 }} de {{ $count }}</span>
    {{ tarea.titulo }}
    @if ($first) { <span class="chip">Primera</span> }
    @if ($last)  { <span class="chip">Última</span> }
  </li>
} @empty {
  <li class="muted">No hay tareas que mostrar.</li>
}
<!-- Renombrar las variables implícitas cuando hay bucles anidados -->
@for (proy of proyectos(); track proy.id; let iProy = $index) {
  @for (t of proy.tareas; track t.id; let iTarea = $index) {
    <p>{{ iProy }}.{{ iTarea }} — {{ t.titulo }}</p>
  }
}
<!-- track admite cualquier expresión, incluida una llamada al componente -->
@for (fila of filas(); track claveDeFila(fila)) { <tr><td>{{ fila.n }}</td></tr> }

Las variables implícitas son $index (posición desde 0), $count (total), $first, $last, $even y $odd. El bloque @empty, si existe, debe ir inmediatamente después del @for.

Por qué track es obligatorio

Cuando la colección cambia, Angular no reconstruye la lista: ejecuta un algoritmo de diffing que compara la lista nueva con la anterior y decide qué vistas crear, destruir y mover. Para comparar necesita una identidad de cada elemento, y eso es exactamente lo que aporta track. En *ngFor, trackBy era opcional y, si faltaba, se usaba la identidad por referencia. Con datos que llegan de HTTP eso es catastrófico: cada respuesta crea objetos nuevos, ninguna referencia coincide y Angular destruye y recrea toda la lista. Angular lo hizo obligatorio para que la decisión sea consciente.

  Antes:   [ {id:1,'A'}, {id:2,'B'}, {id:3,'C'} ]
  Después: [ {id:3,'C'}, {id:1,'A'}, {id:2,'B'} ]      (se ha movido C al principio)
  ── track tarea.id ──── identidad = 1,2,3 → Angular reconoce los tres elementos
     MOVER el nodo DOM de 'C' a la primera posición (0 creados, 0 destruidos)
     · el <input> de la fila 'C' conserva su texto · el foco viaja con el nodo
     · el estado de los componentes hijos se mantiene
  ── track $index ────── identidad = 0,1,2 → las tres POSICIONES siguen existiendo
     no mueve nada; reescribe el contenido de cada posición (3 actualizados)
     · el <input> de la posición 0 conserva su texto, pero ahora pertenece a OTRA
       tarea: el usuario ve su borrador en la fila equivocada
     · el foco se queda en la POSICIÓN, no en el elemento
  ── sin track (hoy imposible) ── identidad = referencia → si el backend devolvió
     objetos nuevos, nada coincide: DESTRUIR 3 y CREAR 3
     · se pierde foco, scroll y estado de los hijos · las animaciones se repiten
     · coste O(n) de creación en cada respuesta HTTP
SituaciónQué usarMotivo
Entidades con identificador estable (lo normal)track item.idIdentidad real: preserva DOM, foco y estado ante reordenaciones, filtros y refrescos.
Primitivos que pueden repetirse (['a','b','a'])track $indextrack item generaría claves duplicadas y Angular lanzaría error.
Lista estática que nunca se reordena ni se filtratrack $indexEs la opción más barata y ninguna de sus desventajas llega a materializarse.
Primitivos únicostrack itemEl propio valor es la identidad.
Clave compuestatrack item.tipo + ':' + item.idtrack admite cualquier expresión, incluidas llamadas a métodos.
tareas.component.htmlINCORRECTO
<!-- La lista se recarga cada 10 s desde la API. Con $index
     el input queda asociado a la POSICIÓN: si el orden
     cambia, el usuario ve su borrador en la fila equivocada
     y pierde el foco al reordenar. -->
@for (tarea of tareas(); track $index) {
  <li>
    <input [value]="tarea.titulo" (blur)="renombrar(tarea, $event)">
    <app-editor-comentarios [tareaId]="tarea.id" />
  </li>
}
tareas.component.htmlCORRECTO
<!-- La identidad es el id de la entidad: Angular mueve los
     nodos en lugar de reescribirlos. El input conserva
     valor y foco, y app-editor-comentarios NO se recrea
     (mantiene su estado y sus peticiones). -->
@for (tarea of tareas(); track tarea.id) {
  <li>
    <input [value]="tarea.titulo" (blur)="renombrar(tarea, $event)">
    <app-editor-comentarios [tareaId]="tarea.id" />
  </li>
}
Claves duplicadas Si la expresión de track produce el mismo valor para dos elementos distintos, Angular avisa (en versiones recientes es un error en desarrollo). Es un síntoma real: o tus datos tienen duplicados que no deberían existir, o has elegido una clave que no identifica nada. No lo silencies pasando a track $index; averigua por qué se repite.

5.4.3 @switch

estado-tarea.component.html
@switch (tarea.estado) {
  @case ('pendiente')  { <span class="chip">Pendiente</span> }
  @case ('en_curso')   { <span class="chip new">En curso</span> }
  @case ('completada') { <span class="chip core">Completada</span> }
  @default             { <span class="chip adv">Desconocido</span> }
}

La comparación es estricta (===), no hay fallthrough ni break, y @default es opcional: si no hay coincidencia y no existe, no se renderiza nada. Con una unión de literales y strictTemplates, el compilador detecta un @case con un valor imposible.

5.4.4 @defer: carga diferida a nivel de plantilla

@defer saca del bundle inicial un trozo de plantilla y sus dependencias, y lo carga cuando se cumple un disparador. Hasta Angular 17 la carga diferida solo existía por ruta; ahora se puede aplicar a un gráfico pesado, un editor enriquecido o un mapa dentro de una página que por lo demás es ligera.

dashboard.component.html
@defer (on viewport; prefetch on idle) {
  <app-grafico-avanzado [datos]="serie()" />
} @placeholder (minimum 300ms) {
  <div class="skeleton" style="height:280px"></div>
} @loading (after 100ms; minimum 500ms) {
  <app-spinner />
} @error {
  <p role="alert">No se ha podido cargar. <button (click)="recargar()">Reintentar</button></p>
}
   ┌──────────────┐  se cumple el trigger  ┌─────────────┐  éxito   ┌───────────────┐
   │ @placeholder │───────────────────────►│  @loading   │─────────►│ contenido del │
   │ (estado ini.)│  empieza la descarga   │ (opcional)  │          │    @defer     │
   └──────────────┘                        └──────┬──────┘          └───────────────┘
   minimum → tiempo MÍNIMO visible (evita parpadeos)  │ fallo ──► @error
   after   → espera antes de MOSTRAR @loading (si carga rápido, ni llega a aparecer)
DisparadorCuándo se activaUso típico
on idleCuando el navegador está ocioso. Es el valor por defecto si no indicas ninguno.Contenido secundario que igualmente quieres tener listo.
on viewportCuando el bloque o el elemento referenciado entra en el viewport, vía IntersectionObserver.Secciones bajo el pliegue: comentarios, gráficos, mapas.
on interactionAl hacer clic o pulsar una tecla sobre el placeholder o la referencia indicada.Acordeones, pestañas, editores que solo se abren bajo demanda.
on hoverAl pasar el puntero o recibir foco sobre el placeholder o la referencia.Previsualizaciones, menús desplegables ricos.
on immediateEn cuanto termina de renderizarse la vista actual.Quitar peso del bundle inicial sin retrasar la interacción.
on timer(2s)Tras el tiempo indicado (ms o s).Avisos diferidos, banners no críticos.
when exprCuando la expresión pasa a verdadera. Es de un solo sentido: una vez cargado, no se descarga.Lógica propia: permisos, pestaña activa, primer scroll.
prefetch on …Descarga el código sin renderizar. Se combina con cualquier disparador.prefetch on idle + on interaction: al pulsar ya está descargado.
defer-avanzado.component.html
<!-- Varios disparadores separados por ';' actúan como un OR -->
@defer (on hover; on timer(5s); prefetch on idle) {
  <app-vista-previa [id]="id" />
} @placeholder { <div class="preview-box">Pasa el ratón para la vista previa</div> }
<!-- Disparador anclado a otro elemento mediante su referencia -->
<button #abrir>Ver comentarios</button>
@defer (on interaction(abrir); prefetch on hover(abrir)) {
  <app-comentarios [tareaId]="tarea.id" />
} @placeholder { <p class="muted">Los comentarios se cargarán al pulsar.</p> }
<!-- when con una señal: control total desde la clase -->
@defer (when pestanaActiva() === 'estadisticas') { <app-estadisticas /> }
Qué se difiere realmente (y qué no)

Angular solo puede sacar del bundle inicial las dependencias usadas exclusivamente dentro del bloque (deferrable dependencies), y deben ser componentes, directivas o pipes standalone. Una dependencia no se difiere si: se usa también fuera del bloque, aunque sea en otro punto de la misma plantilla; se usa en el @placeholder, @loading o @error, que se cargan de forma anticipada por definición; está declarada en un NgModule; o se referencia desde la clase, por ejemplo en un viewChild(GraficoComponent), porque entonces la importación es estática en el TypeScript. Un servicio inyectado en el constructor tampoco se difiere: si el peso está en una librería, muévela al componente diferido, no al servicio del padre.

@defer y renderizado en servidor Con SSR clásico el servidor renderiza siempre el @placeholder: la carga diferida es un fenómeno de cliente, así que ese contenido no se indexa. Desde Angular 19 existe la hidratación incremental, que añade disparadores hydrate (por ejemplo @defer (hydrate on viewport)): el servidor sí renderiza el contenido real y el cliente pospone solo la descarga del JavaScript y la hidratación. Comprueba tu versión antes de apoyarte en ello y no metas contenido crítico para SEO dentro de un @defer sin hidratación incremental.

5.4.5 Migración desde las directivas estructurales

Antes (directivas)Ahora (bloques)Qué mejora
*ngIf="cond"@if (cond) { }No hay que importar NgIf; sintaxis familiar para cualquier programador.
*ngIf="c; else tpl" + ng-template@if (c) { } @else { }Desaparece la indirección del ng-template con nombre.
*ngIf="v$ | async as v"@if (v$ | async; as v) { }Mismo comportamiento, sintaxis explícita.
*ngFor="let x of xs; trackBy: fn"@for (x of xs; track x.id) { }track es obligatorio y admite una expresión, no solo una función de firma fija.
*ngFor + *ngIf="!xs.length"@for … @empty { }El caso vacío deja de ser una condición duplicada que se desincroniza.
[ngSwitch] + *ngSwitchCase@switch + @caseComparación estricta y comprobación de tipos de cada caso.
(no existía)@deferCarga diferida por fragmento de plantilla, no solo por ruta.
terminal
# Migración automática (Angular 17+): reescribe *ngIf/*ngFor/[ngSwitch] a bloques
ng generate @angular/core:control-flow
# Revisa SIEMPRE el resultado: elige 'track $index' cuando no encuentra un id claro
git diff

Las ventajas medibles del control flow nativo son: menos JavaScript (los bloques no arrastran NgIf, NgForOf ni NgSwitch), mejor rendimiento en listas (el algoritmo de diffing de @for se reescribió y es sustancialmente más rápido), mejor comprobación de tipos y mensajes de error, porque el compilador conoce la estructura en lugar de verla como una microsintaxis dentro de un atributo, y legibilidad: la estructura es visible en la indentación.

5.5 ng-template, ng-container y plantillas parametrizables

outlets.component.html
<tr><ng-container *ngTemplateOutlet="celdas; context: { $implicit: fila }"></ng-container></tr>
<!-- $implicit se recibe con 'let-x' sin nombre; el resto, con let-x="clave" -->
<ng-template #celdas let-fila let-i="indice" let-total="total">
  <td>{{ i }} / {{ total }}</td>
  <td>{{ fila.titulo }}</td>
</ng-template>
<ng-container [ngTemplateOutlet]="celdas"
              [ngTemplateOutletContext]="{ $implicit: filaA, indice: 0, total: 2 }"></ng-container>

5.5.1 Patrón: componente con plantillas inyectadas

Este es el patrón que convierte un componente rígido en una pieza reutilizable de verdad. La tabla sabe paginar, ordenar y gestionar el estado vacío; no sabe cómo se pinta una celda. Eso lo aporta quien la usa, en forma de ng-template.

src/app/shared/tabla.component.ts
export interface FilaCtx<T> { $implicit: T; indice: number; ultima: boolean; }
/** Directiva marcadora: da un tipo al ng-template y permite tipar 'let-x'. */
@Directive({ selector: '[appFila]' })
export class FilaDirective<T> {
  static ngTemplateContextGuard<T>(d: FilaDirective<T>, c: unknown): c is FilaCtx<T> { return true; }
}
@Component({
  selector: 'app-tabla',
  imports: [NgTemplateOutlet],
  template: `
    <table><caption>{{ titulo() }}</caption><tbody>
      @for (item of datos(); track claveDe()(item); let i = $index, ultima = $last) {
        <tr><ng-container [ngTemplateOutlet]="filaTpl() ?? null"
          [ngTemplateOutletContext]="{ $implicit: item, indice: i, ultima: ultima }">
        </ng-container></tr>
      } @empty {
        <tr><td class="muted">{{ textoVacio() }}</td></tr>
      }
    </tbody></table>`,
})
export class TablaComponent<T> {
  readonly datos = input.required<readonly T[]>();
  readonly claveDe = input.required<(item: T) => string | number>();
  readonly titulo = input(''); readonly textoVacio = input('Sin resultados');
  // Recoge el ng-template marcado con appFila que el padre ha proyectado
  readonly filaTpl = contentChild(FilaDirective, { read: TemplateRef });
}
lista-tareas.component.html · uso
<app-tabla [datos]="tareas()" [claveDe]="porId" titulo="Mis tareas">
  <ng-template appFila let-tarea let-i="indice" let-ultima="ultima">
    <td>{{ i + 1 }}</td>
    <td><a [routerLink]="['/tareas', tarea.id]">{{ tarea.titulo }}</a></td>
    <td>{{ tarea.vence | date:'shortDate' }}</td>
    <td>@if (ultima) { <span class="chip">Último</span> }</td>
  </ng-template>
</app-tabla>

5.5.2 NgComponentOutlet: componentes decididos en ejecución

widget.component.ts
@Component({
  selector: 'app-widget',
  imports: [NgComponentOutlet],
  template: `@if (componente(); as cmp) {
    <ng-container [ngComponentOutlet]="cmp" [ngComponentOutletInputs]="entradas()"></ng-container>
  }`,
})
export class WidgetComponent {
  readonly tipo = input.required<'grafico' | 'tabla'>();
  readonly config = input<Record<string, unknown>>({});
  readonly entradas = computed(() => ({ config: this.config() }));  // desde Angular 16.2
  readonly componente = computed(() => REGISTRO[this.tipo()] ?? null);
}
Cuándo usar cada mecanismo

ng-content (capítulo 3): el padre aporta contenido fijo; la proyección es estática y se crea una sola vez. ng-template + ngTemplateOutlet: el padre aporta una plantilla que el hijo instancia N veces con datos distintos; es el patrón de tablas, listas y selectores genéricos. NgComponentOutlet: ni siquiera el tipo del componente se conoce al compilar; es lo que necesitas para paneles configurables, formularios generados desde un esquema o sistemas de plugins.

5.6 Pipes en plantillas

5.6.1 async y su papel con OnPush

AsyncPipe hace cuatro cosas que a mano se olvidan: se suscribe, devuelve el último valor, llama a markForCheck() en cada emisión y cancela la suscripción al destruirse la vista. Ese markForCheck() es lo que permite que un componente OnPush se actualice aunque el dato no haya llegado por una entrada.

panel.component.tsINCORRECTO
@Component({ changeDetection: ChangeDetectionStrategy.OnPush,
             template: `<p>{{ total }}</p>` })
export class PanelComponent implements OnInit {
  total = 0;
  ngOnInit(): void {
    // 1) Fuga: nadie cancela la suscripción.
    // 2) Con OnPush, asignar una propiedad NO marca la
    //    vista para comprobación: el <p> nunca cambia.
    this.svc.total$.subscribe((t) => (this.total = t));
  }
}
panel.component.tsCORRECTO
@Component({ changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [AsyncPipe],
  template: `<p>{{ total$ | async }}</p>   <!-- A -->
             <p>{{ total() }}</p>          <!-- B -->` })
export class PanelComponent {
  private readonly svc = inject(TotalService);
  readonly total$ = this.svc.total$;
  readonly total = toSignal(this.svc.total$, { initialValue: 0 });
}
Un | async por suscripción Cada aparición de | async sobre el mismo observable crea una suscripción independiente. Si es frío y dispara una petición HTTP, tres | async son tres peticiones. Soluciones: @if (x$ | async; as x), @let x = x$ | async;, shareReplay(1) o pasar a toSignal, que es una única suscripción por definición.

5.6.2 Formateo con locale y encadenado

src/main.ts · configuración del idioma
import { registerLocaleData } from '@angular/common';
import localeEs from '@angular/common/locales/es';
import localeEsExtra from '@angular/common/locales/extra/es';
registerLocaleData(localeEs, 'es-ES', localeEsExtra);
bootstrapApplication(AppComponent, {
  providers: [
    { provide: LOCALE_ID, useValue: 'es-ES' },
    { provide: DEFAULT_CURRENCY_CODE, useValue: 'EUR' },
  ],
});
formato.component.html
<p>{{ tarea.creada | date:'dd/MM/yyyy HH:mm' }}</p>
<p>{{ tarea.creada | date:'short':'+0200':'en-US' }}</p>   <!-- zona y locale puntuales -->
<p>{{ 1234.5 | number:'1.2-2' }}</p>                       <!-- 1.234,50 -->
<p>{{ 0.1234 | percent:'1.1-1' }}</p>                      <!-- 12,3 % -->
<p>{{ precio | currency:'EUR':'symbol':'1.2-2' }}</p>
<p>{{ tarea.titulo | slice:0:40 | titlecase }}</p>          <!-- encadenado, izq. a der. -->
<p>{{ base + iva | currency:'EUR' }}</p>                   <!-- ¡aplica a (base + iva)! -->

5.6.3 El coste de los pipes impuros

Un pipe puro (el valor por defecto) solo se reejecuta cuando cambia alguno de sus argumentos, comparado por identidad; Angular memoiza el resultado anterior. Un pipe impuro (pure: false) se ejecuta en cada ciclo de detección de cambios, sin excepción.

 Pipe puroPipe impuroSeñal computada
Cuándo recalculaSi cambia la referencia de un argumentoEn cada ciclo de detecciónSi cambia una señal de la que depende
MemoizaciónSí, del último resultadoNoSí, y perezosa: solo si alguien la lee
Detecta mutaciones de arraysNo (hay que crear un array nuevo)No (mismo motivo)
Coste típicoDespreciableAlto en listas grandesDespreciable
Reutilizable entre componentesNo: vive en una clase concreta
filtrar.pipe.tsINCORRECTO
@Pipe({ name: 'filtrar', pure: false })
export class FiltrarPipe implements PipeTransform {
  // Se ejecuta en CADA ciclo y para CADA uso del pipe.
  // Con 500 tareas y 60 ciclos por segundo son 30.000
  // comparaciones por segundo.
  transform(items: Tarea[], termino: string): Tarea[] {
    return items.filter((t) => t.titulo.toLowerCase().includes(termino.toLowerCase()));
  }
}
// plantilla: @for (t of tareas | filtrar:termino; track t.id)
lista.component.tsCORRECTO
export class ListaComponent {
  readonly tareas = input.required<readonly Tarea[]>();
  readonly termino = signal('');
  // Recalcula SOLO si cambian tareas() o termino(), y solo
  // si la plantilla lo lee. Además está tipado y se puede
  // probar sin renderizar nada.
  readonly filtradas = computed(() => {
    const q = this.termino().toLowerCase();
    return this.tareas().filter((t) => t.titulo.toLowerCase().includes(q));
  });
}
// plantilla: @for (t of filtradas(); track t.id)
Regla de decisión Si la transformación depende solo del estado del componente, usa una señal computada. Si es una transformación de presentación reutilizable en toda la aplicación (formatear un IBAN, humanizar una duración), escribe un pipe puro. Un pipe impuro solo se justifica cuando debes reaccionar a algo que no pasa por argumentos, como AsyncPipe, y en ese caso ya existe uno oficial.

5.7 Formularios: panorama y criterio de elección

Angular ofrece dos APIs de formularios que no se mezclan dentro del mismo formulario. La diferencia esencial no es sintáctica: es dónde vive la fuente de verdad. En los reactivos (ReactiveFormsModule) el modelo se construye en la clase con objetos explícitos y la plantilla se enlaza a él. En los template-driven (FormsModule) el modelo lo construyen las directivas ngModel a partir de la plantilla, de forma asíncrona.

CriterioReactivosTemplate-driven
Dónde vive el modeloEn la clase, explícito y accesible antes de renderizarEn la plantilla; lo generan las directivas
Fuente de verdadEl árbol de AbstractControlLa propiedad enlazada con ngModel
SincroníaSíncrono: el control existe desde el constructorAsíncrono: los controles aparecen tras el primer ciclo
TipadoFuerte y verificado (desde Angular 14)Débil: el valor del formulario es any
ValidaciónFunciones puras en TypeScript, testeables aisladamenteDirectivas en el marcado
Validación dinámicaNatural: setValidators() + updateValueAndValidity()Difícil y frágil
Campos dinámicosFormArray, addControl, setControlSolo con @for y nombres calculados; sin control real
TestabilidadAlta: se prueba el modelo sin renderizar la vistaBaja: exige TestBed, fixture.whenStable() y esperas
EscalabilidadExcelente en formularios grandes y anidadosSe degrada rápido a partir de 6-8 campos
Curva de aprendizajeMás pronunciada: hay que aprender el modeloMuy suave
Integración con RxJS y señalesDirecta: valueChanges, statusChanges, toSignalIndirecta y limitada
Criterio de elección Usa template-driven si el formulario tiene tres o cuatro campos, sin validación cruzada ni asíncrona, sin campos que aparezcan o desaparezcan y sin necesidad de testearlo a fondo: una búsqueda, un «suscríbete al boletín», un diálogo de confirmación. Usa reactivos en todo lo demás y desde luego en cualquier formulario del núcleo del negocio. No mezcles ambos enfoques en un mismo formulario; sí puedes tener formularios de cada tipo en pantallas distintas de la misma aplicación.

5.8 Formularios reactivos a fondo

5.8.1 La jerarquía de controles

Todo se apoya en la clase abstracta AbstractControl y tres implementaciones. FormGroup y FormArray son composiciones: es el patrón Composite del catálogo clásico, y por eso valid, value o reset() funcionan igual en una hoja que en toda la rama.

  AbstractControl  (valor · estado · validadores · valueChanges · statusChanges)
        ├── FormControl<T>   hoja: un único valor
        ├── FormGroup<T>     conjunto FIJO de controles con nombre         → value: objeto
        ├── FormRecord<T>    conjunto DINÁMICO de controles del MISMO tipo → value: objeto
        └── FormArray<T>     lista ORDENADA de controles del mismo tipo    → value: array
  ── Ejemplo: formulario de creación de tarea ──────────────────────────────
  FormGroup  (raíz)
   ├─ 'titulo'     FormControl<string>
   ├─ 'prioridad'  FormControl<'baja' | 'media' | 'alta'>
   ├─ 'etiquetas'  FormArray<FormControl<string>>
   │                ├─ [0] FormControl<string>  'backend'
   │                └─ [1] FormControl<string>  'urgente'
   └─ 'plazo'      FormGroup      ← validador cruzado: desde <= hasta
                    ├─ 'desde' FormControl<string>
                    └─ 'hasta' FormControl<string>
  ── Reglas de propagación ─────────────────────────────────────────────────
   valor     hijo ──► padre    el padre compone su value con el de sus hijos
   estado    hijo ──► padre    un hijo INVALID invalida a TODOS sus ancestros
   touched   hijo ──► padre    markAllAsTouched() va al revés: padre ──► hijos
   dirty     hijo ──► padre
   disabled  padre ──► hijo    deshabilitar un grupo deshabilita su subárbol
   reset()   padre ──► hijo
src/app/tareas/tarea-form.component.ts · construcción del modelo
private readonly fb = inject(NonNullableFormBuilder);
// Opción A: construcción manual. Verbosa pero explícita.
readonly manual = new FormGroup({
  titulo: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
  etiquetas: new FormArray<FormControl<string>>([]),
});
// Opción B: FormBuilder. La forma habitual en producción.
readonly form = this.fb.group({
  titulo:    this.fb.control('', [Validators.required, Validators.maxLength(120)]),
  prioridad: this.fb.control<'baja' | 'media' | 'alta'>('media'),
  // Un control puede admitir null explícitamente aunque el builder sea nonNullable
  responsableId: this.fb.control<number | null>(null),
  etiquetas: this.fb.array<FormControl<string>>([]),
  plazo: this.fb.group({ desde: this.fb.control(''), hasta: this.fb.control('') }),
});
// Acceso tipado al FormArray: SIEMPRE con un getter, nunca con 'as any'
get etiquetas(): FormArray<FormControl<string>> { return this.form.controls.etiquetas; }
anadirEtiqueta(v = ''): void {
  this.etiquetas.push(this.fb.control(v, [Validators.required, Validators.maxLength(24)]));
}
quitarEtiqueta(i: number): void {
  this.etiquetas.removeAt(i);
  this.etiquetas.markAsDirty();   // removeAt no marca el array como sucio por sí solo
}

5.8.2 Formularios tipados: value frente a getRawValue()

Desde Angular 14 los formularios son genéricos y el tipo se infiere del valor inicial. De ahí salen las dos sorpresas más frecuentes del sistema.

tipado.ts
// SORPRESA 1: por defecto todo control admite null, porque reset() sin
// argumentos devuelve el control a null.
const c1 = new FormControl('hola');                        // FormControl<string | null>
c1.reset();  c1.value;                                     // null
const c2 = new FormControl('hola', { nonNullable: true }); // FormControl<string>
c2.reset();  c2.value;                                     // 'hola' ← vuelve al INICIAL
// SORPRESA 2: form.value es Partial cuando hay controles deshabilitados.
const form = new FormGroup({
  email:  new FormControl('a@b.c', { nonNullable: true }),
  codigo: new FormControl('X-1',   { nonNullable: true }),
});
form.controls.codigo.disable();
form.value;          // Partial<{email: string; codigo: string}> → { email: 'a@b.c' }
form.getRawValue();  // { email: string; codigo: string } → { email: 'a@b.c', codigo: 'X-1' }
// Derivar el tipo del formulario sin duplicar la definición
type TareaValor = ReturnType<typeof form.getRawValue>;
// FormRecord: claves dinámicas, controles del mismo tipo. Útil para
// "un checkbox por permiso" cuando los permisos vienen del servidor.
const permisos = new FormRecord<FormControl<boolean>>({});
for (const p of permisosDelServidor) {
  permisos.addControl(p.clave, new FormControl(p.activo, { nonNullable: true }));
}
// Escotilla de emergencia: FormGroup<any> desactiva el tipado del grupo. Válido
// en un motor de formularios genérico; jamás en código de negocio.
const dinamico: FormGroup<any> = this.fb.group({});
La regla que evita el 90 % de los bugs de formularios tipados

Un control deshabilitado queda excluido del value del grupo y de la validación (un control disabled siempre es valid). Por eso TypeScript tipa value como Partial: no puede saber al compilar qué controles estarán habilitados.

Al enviar usa siempre getRawValue() si algún campo puede estar deshabilitado. Y si un campo es de solo lectura pero debe viajar al servidor, no lo deshabilites: usa readonly en el input, que sí conserva el valor. En un FormArray tipado el genérico es el del control hijo: FormArray<FormControl<string>> produce string[].

5.8.3 Estados de un control y clases CSS

PropiedadSignificadoClase CSS
pristine / dirtyEl valor nunca cambió por interacción del usuario / sí cambióng-pristine / ng-dirty
untouched / touchedEl control nunca recibió y perdió el foco / síng-untouched / ng-touched
valid / invalidTodos los validadores pasan / alguno fallang-valid / ng-invalid
pendingHay validadores asíncronos ejecutándoseng-pending
disabled / enabledExcluido del valor y de la validación / activo(sin clase; se refleja en el atributo disabled)
status'VALID', 'INVALID', 'PENDING' o 'DISABLED'
errorsValidationErrors | null: una clave por validador fallido
dirty no es touched dirty habla del valor; touched, del foco. Un usuario puede entrar y salir de un campo sin escribir: queda touched y pristine. Además, setValue() desde el código no marca el control como dirty: solo lo hace la interacción del usuario a través del ControlValueAccessor. Si tu código cambia un valor y quieres que cuente como edición, llama a markAsDirty().

La estrategia correcta para mostrar errores

Mostrar el error en cuanto el campo es inválido significa gritarle «email no válido» al usuario después de la primera letra. Es hostil y contraproducente: se aprende a ignorar los mensajes. La regla profesional es que un error se muestra cuando el control es inválido Y (el usuario ya salió del campo O ya intentó enviar).

form.component.htmlINCORRECTO
<input formControlName="email">
<!-- Aparece con el campo vacío, antes de tocarlo -->
@if (form.controls.email.invalid) {
  <p class="error">Email obligatorio</p>
}
<!-- Y al enviar, si el usuario no ha tocado nada, no se
     muestra NINGÚN error: parece que el botón está roto -->
<button (click)="enviar()">Guardar</button>
form.component.html · y la claseCORRECTO
<input formControlName="email" aria-describedby="err-email"
       [attr.aria-invalid]="mostrarError('email')">
@if (mostrarError('email')) {
  <p id="err-email" class="error" role="alert">{{ mensajeDe(form.controls.email) }}</p>
}
<button type="submit">Guardar</button>
form.component.ts
readonly enviado = signal(false);
mostrarError(nombre: keyof typeof this.form.controls): boolean {
  const c = this.form.controls[nombre];
  return c.invalid && (c.touched || this.enviado());
}
enviar(): void {
  this.enviado.set(true);
  if (this.form.invalid) {
    // Marca TODO el árbol como touched: sin esto, un formulario que el
    // usuario no ha tocado no muestra ningún error al pulsar Guardar.
    this.form.markAllAsTouched();
    this.enfocarPrimerError();
    return;
  }
  this.api.crear(this.form.getRawValue()).subscribe();
}

5.8.4 updateOn: cuándo se actualiza el modelo

ValorEl valor y la validación se actualizan…Cuándo usarlo
'change' (por defecto)En cada pulsación de teclaCampos con feedback inmediato: medidor de fuerza de contraseña, contador de caracteres.
'blur'Al perder el focoFormularios largos, validadores caros, validación asíncrona. Reduce drásticamente los ciclos.
'submit'Solo al enviar el formularioAsistentes por pasos y formularios sin feedback intermedio.
update-on.ts
const email = new FormControl('', {
  nonNullable: true,
  validators: [Validators.required, Validators.email],
  asyncValidators: [emailDisponible(api)],
  updateOn: 'blur',        // el validador asíncrono se ejecuta UNA vez, al salir
});
// A nivel de grupo se hereda en los hijos que no lo especifiquen
const form = this.fb.group({ /* ... */ }, { updateOn: 'blur' });
// Con 'blur' y 'submit', value NO se actualiza mientras se escribe: si tienes un
// contador de caracteres en vivo, ese campo debe quedarse en 'change'.

5.8.5 setValue, patchValue, reset, disable

MétodoComportamientoCuándo usarlo
setValue(v)Estricto: el objeto debe contener exactamente todos los controles; si falta o sobra uno, lanza error.Cargar una entidad completa. El error avisa si el formulario y el DTO se han desincronizado.
patchValue(v)Permisivo: aplica las claves que reconoce e ignora las demás en silencio.Actualizaciones parciales. Ojo: una errata en una clave no produce ningún aviso.
reset()Devuelve el valor a null (o al inicial si es nonNullable) y deja el control pristine y untouched.Vaciar el formulario tras un envío correcto.
reset(v)Igual, fijando v como nuevo valor de partida.form.reset(form.getRawValue()) deja el formulario limpio con los datos guardados.
disable()/enable()Saca o devuelve el control al value y a la validación. Emite valueChanges del padre.Campos condicionales. Usa { emitEvent: false } si lo llamas desde una suscripción.
updateValueAndValidity()Fuerza el recálculo del valor y del estado y propaga hacia arriba.Obligatorio tras setValidators()/clearValidators(): si no, el cambio no se aplica.
addControl / removeControl / setControlAñaden, quitan o sustituyen un control del grupo.Formularios dinámicos. setControl sustituye el control entero: se pierden su estado y sus suscripciones.
campos-condicionales.ts · validadores dinámicos
// Si el tipo de tarea es 'recurrente', la periodicidad pasa a ser obligatoria
this.form.controls.tipo.valueChanges.pipe(takeUntilDestroyed()).subscribe((tipo) => {
  const p = this.form.controls.periodicidad;
  if (tipo === 'recurrente') {
    p.setValidators([Validators.required, Validators.min(1)]);
    p.enable({ emitEvent: false });
  } else {
    p.clearValidators();
    p.reset('', { emitEvent: false });
    p.disable({ emitEvent: false });
  }
  // SIN esta línea los validadores nuevos no se aplican hasta el siguiente
  // cambio de valor: el formulario parece "válido por error".
  p.updateValueAndValidity({ emitEvent: false });
});
emitEvent: false y los bucles infinitos Si dentro de una suscripción a valueChanges modificas otro control sin { emitEvent: false }, ese cambio emite otro valueChanges, que vuelve a entrar en tu suscripción. Con suerte el navegador se congela y lo ves enseguida; con menos suerte solo se dispara en un caso concreto en producción. Norma: toda escritura de un control desde una suscripción a valueChanges lleva { emitEvent: false }.

5.8.6 valueChanges, statusChanges y señales

buscador.component.ts
readonly termino = new FormControl('', { nonNullable: true });
// 1) Observable → señal: una sola suscripción, cancelada al destruir el componente
readonly texto = toSignal(
  this.termino.valueChanges.pipe(debounceTime(300), map((t) => t.trim()), distinctUntilChanged()),
  { initialValue: '' },
);
// 2) Búsqueda con cancelación de la petición anterior
readonly resultados$ = this.termino.valueChanges.pipe(
  startWith(this.termino.value),          // valueChanges NO emite el valor actual
  debounceTime(300),
  distinctUntilChanged(),
  switchMap((q) => this.api.buscar(q)),   // switchMap cancela la búsqueda anterior
);
// 3) Estado del formulario como señal
readonly estado = toSignal(this.form.statusChanges, { initialValue: this.form.status });
readonly puedeEnviar = computed(() => this.estado() === 'VALID');
valueChanges no emite el valor inicial Es un Subject, no un BehaviorSubject. Si necesitas el valor actual al suscribirte, añade startWith(control.value) o usa toSignal(..., { initialValue }). Desde Angular 18 existe además control.events, un único observable con eventos tipados (ValueChangeEvent, StatusChangeEvent, TouchedChangeEvent, PristineChangeEvent, FormSubmittedEvent, FormResetEvent); comprueba tu versión antes de usarlo.

5.8.7 Formularios dinámicos generados desde configuración

src/app/shared/form-dinamico.ts
export interface CampoConfig {
  clave: string; etiqueta: string;
  tipo: 'texto' | 'numero' | 'fecha' | 'seleccion' | 'booleano';
  valorInicial?: unknown; obligatorio?: boolean; minLength?: number; max?: number;
  opciones?: readonly { valor: string; texto: string }[];
}
/** Traduce la configuración declarativa a validadores de Angular. */
function validadoresDe(c: CampoConfig): ValidatorFn[] {
  const v: ValidatorFn[] = [];
  if (c.obligatorio) v.push(Validators.required);
  if (c.minLength !== undefined) v.push(Validators.minLength(c.minLength));
  if (c.max !== undefined) v.push(Validators.max(c.max));
  return v;
}
export function construirFormulario(campos: readonly CampoConfig[]): FormGroup {
  const controles: Record<string, FormControl> = {};
  for (const c of campos) {
    const inicial = c.valorInicial ?? (c.tipo === 'booleano' ? false : '');
    controles[c.clave] = new FormControl(inicial, { nonNullable: true, validators: validadoresDe(c) });
  }
  return new FormGroup(controles);
}
form-dinamico.component.html
<form [formGroup]="form" (ngSubmit)="enviar()">
  @for (campo of campos(); track campo.clave) {
    <div class="campo">
      <label [attr.for]="campo.clave">{{ campo.etiqueta }}</label>
      @switch (campo.tipo) {
        @case ('booleano') { <input type="checkbox" [id]="campo.clave" [formControlName]="campo.clave"> }
        @case ('seleccion') {
          <select [id]="campo.clave" [formControlName]="campo.clave">
            @for (o of campo.opciones ?? []; track o.valor) { <option [value]="o.valor">{{ o.texto }}</option> }
          </select>
        }
        @default { <input [id]="campo.clave" [type]="tipoHtml(campo)" [formControlName]="campo.clave"> }
      }
      <app-errores [control]="form.get(campo.clave)!" />
    </div>
  }
  <button type="submit">Guardar</button>
</form>

5.9 Validación

5.9.1 El ciclo de validación de un control

   el usuario escribe · o el código llama a setValue()
                     │   ¿updateOn?  change (cada tecla) · blur · submit
                     ▼
   ┌──────────────────────────────────────────────────────┐
   │ 1. El control guarda el valor → emite valueChanges    │
   ├──────────────────────────────────────────────────────┤
   │ 2. Validadores SÍNCRONOS (required, email, …).        │
   │    Se ejecutan TODOS y sus errores se fusionan en un  │
   │    único objeto: { required: true, minlength: {...} } │
   └───────────┬──────────────────────────┬───────────────┘
     hay errores│                          │sin errores
                ▼                          ▼
        status = INVALID       ┌─────────────────────────────┐
        errors = { ... }       │ 3. ¿Hay validadores ASYNC?  │
        (los async NI se       └──────┬──────────────┬───────┘
         llegan a ejecutar)        no │              │ sí
                │                     ▼              ▼
                │             status = VALID   status = PENDING (se suscribe)
                │             errors = null           │
                │                        primer valor emitido + complete
                │                     ┌───────────────┴───────────────┐
                │            con errores│                             │null
                │                       ▼                             ▼
                │                 status = INVALID              status = VALID
                └───────────────┬──────────────────────────────────────┘
                                ▼
   ┌──────────────────────────────────────────────────────┐
   │ 4. Propagación al padre: recalcula value y status     │
   │    (un solo hijo INVALID invalida toda la raíz)       │
   │ 5. Emite statusChanges · se actualizan las clases ng-*│
   └──────────────────────────────────────────────────────┘
Los validadores asíncronos solo corren si los síncronos pasan Es una optimización deliberada: no tiene sentido preguntar al servidor si "a" está disponible como email cuando Validators.email ya dice que no es un email. Consecuencia práctica: mientras el campo esté vacío y sea required, tu validador de unicidad no se ejecutará ni una vez.

5.9.2 Validadores integrados

ValidadorCompruebaClave de error y contenido
Validators.requiredValor no vacío (null, undefined, '' y array vacío fallan){ required: true }
Validators.requiredTrueEl valor es exactamente true (checkbox de condiciones){ required: true }
Validators.emailFormato de correo; expresión regular permisiva, no comprueba el dominio{ email: true }
Validators.min(n) / max(n)Valor numérico dentro del límite{ min: { min, actual } } / { max: {...} }
Validators.minLength(n) / maxLength(n)Longitud de cadena o array{ minlength: { requiredLength, actualLength } }
Validators.pattern(re)Coincide con la expresión regular (anclada si pasas una cadena){ pattern: { requiredPattern, actualValue } }
Validators.nullValidatorNunca falla; marcador de posición
Validators.compose([…]) / composeAsync([…])Combinan varios validadores en unoFusión de los errores de todos
Cuidado con minLength y required Validators.minLength(3) no falla con la cadena vacía: los validadores de longitud ignoran los valores vacíos por diseño, para no duplicar el mensaje de «obligatorio». Si quieres que un campo vacío sea inválido necesitas required además. Validators.email también acepta la cadena vacía.

5.9.3 Validadores personalizados síncronos

src/app/shared/validadores.ts
/** Validador de CONTROL parametrizado (patrón factoría). */
export function sinEspacios(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const valor = control.value as string | null;
    if (valor === null || valor === '') return null;    // no dupliques 'required'
    return /\s/.test(valor) ? { sinEspacios: true } : null;
  };
}
export function prohibido(...palabras: readonly string[]): ValidatorFn {
  const set = new Set(palabras.map((p) => p.toLowerCase()));
  return (control) => set.has(String(control.value ?? '').toLowerCase())
    ? { prohibido: { valor: control.value } } : null;
}
/** Validador de GRUPO (cross-field): confirmar contraseña. */
export const contrasenasCoinciden: ValidatorFn = (grupo): ValidationErrors | null => {
  const pass = grupo.get('password')?.value;
  const conf = grupo.get('confirmacion')?.value;
  if (!pass || !conf) return null;                      // aún no molestamos al usuario
  return pass === conf ? null : { contrasenasDistintas: true };
};
/** Validador de GRUPO: rango de fechas coherente. */
export function rangoFechas(campoDesde: string, campoHasta: string): ValidatorFn {
  return (grupo) => {
    const desde = grupo.get(campoDesde)?.value as string | null;
    const hasta = grupo.get(campoHasta)?.value as string | null;
    if (!desde || !hasta || new Date(desde) <= new Date(hasta)) return null;
    // Además del error del grupo, marcamos el control concreto para pintar el
    // borde rojo en el campo correcto SIN borrar sus otros errores.
    const ctrl = grupo.get(campoHasta);
    ctrl?.setErrors({ ...(ctrl.errors ?? {}), rangoInvertido: true });
    return { rangoFechas: { desde, hasta } };
  };
}
registro.component.ts · aplicar un validador de grupo
readonly form = this.fb.group(
  {
    email:        this.fb.control('', [Validators.required, Validators.email]),
    password:     this.fb.control('', [Validators.required, Validators.minLength(10)]),
    confirmacion: this.fb.control('', [Validators.required]),
  },
  { validators: [contrasenasCoinciden] },   // ← validador a nivel de GRUPO
);
// El error vive en el GRUPO, no en el control: form.errors → { contrasenasDistintas: true }
// y form.controls.confirmacion.errors → null. En la plantilla se comprueba con
// form.hasError('contrasenasDistintas').
setErrors() sobrescribe control.setErrors({ x: true }) reemplaza todo el objeto de errores, incluidos los que pusieron los validadores. Si necesitas añadir uno (típico al mapear errores del servidor), fusiona: control.setErrors({ ...(control.errors ?? {}), servidor: msg }). Y recuerda que updateValueAndValidity() volverá a ejecutar los validadores y borrará lo que hayas puesto a mano.

5.9.4 Validadores asíncronos

email.validator.tsINCORRECTO
export function emailDisponible(api: UsuariosApi): AsyncValidatorFn {
  return (control) =>
    // 1) Una petición HTTP POR TECLA pulsada.
    // 2) Sin cancelar las anteriores: las respuestas pueden
    //    llegar desordenadas y dejar el control incorrecto.
    // 3) Si la API falla, el observable emite error y el
    //    control se queda en PENDING para siempre: el
    //    formulario nunca se puede enviar.
    api.existe(control.value).pipe(
      map((existe) => (existe ? { emailEnUso: true } : null)),
    );
}
email.validator.tsCORRECTO
export function emailDisponible(api: UsuariosApi): AsyncValidatorFn {
  return (control) =>
    timer(400).pipe(               // debounce: reinicia en cada tecla
      switchMap(() => api.existe(control.value)),
      map((existe) => (existe ? { emailEnUso: true } : null)),
      catchError(() => of(null)),  // si la API falla, no bloqueamos el envío
      first(),                     // COMPLETA: sin esto, PENDING eterno
    );
}
// Mejor todavía: updateOn: 'blur' en el control, para que el
// validador se ejecute una sola vez, al salir del campo.
Las tres reglas de oro de un validador asíncrono

1. Debe completar. Angular deja el control en PENDING hasta que el observable emite y completa. Uno que emite pero no completa (derivado de un Subject) deja el formulario colgado. Usa first() o take(1): HttpClient completa solo, pero en cuanto añades operadores conviene ser explícito.

2. Debe capturar errores. Un error del observable no marca el control como inválido: lo deja indeterminado. catchError(() => of(null)) es la política habitual (fallar en abierto), aunque en un caso crítico puede interesar lo contrario.

3. Debe cancelar y limitar. timer(n) + switchMap o updateOn: 'blur'. Un validador asíncrono sin freno es un ataque de denegación de servicio contra tu propio backend.

5.9.5 Mostrar errores de forma escalable

Escribir un @if por cada clave de error y por cada campo no escala: veinte campos con tres validadores son sesenta bloques que mantener y traducir. La solución es un diccionario de mensajes más un componente de errores reutilizable.

src/app/shared/mensajes-error.ts
/** Una sola fuente de verdad para los textos de error de toda la aplicación. */
export const MENSAJES: Record<string, (d: any) => string> = {
  required:    () => 'Este campo es obligatorio.',
  email:       () => 'Introduce una dirección de correo válida.',
  minlength:   (d) => `Necesita al menos ${d.requiredLength} caracteres (tiene ${d.actualLength}).`,
  maxlength:   (d) => `No puede superar los ${d.requiredLength} caracteres.`,
  min:         (d) => `El valor mínimo es ${d.min}.`,
  pattern:     () => 'El formato no es válido.',
  sinEspacios: () => 'No se permiten espacios.',
  emailEnUso:  () => 'Ese correo ya está registrado.',
  servidor:    (d) => String(d),          // mensaje literal devuelto por la API
};
export function primerMensaje(errores: ValidationErrors | null): string | null {
  if (!errores) return null;
  for (const clave of Object.keys(errores)) {
    if (MENSAJES[clave]) return MENSAJES[clave](errores[clave]);
  }
  return 'Valor no válido.';   // red de seguridad: nunca dejes al usuario sin explicación
}
src/app/shared/errores.component.ts
@Component({
  selector: 'app-errores',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `@if (mensaje(); as m) { <p class="error" [id]="idMensaje()" role="alert">{{ m }}</p> }`,
})
export class ErroresComponent {
  readonly control = input.required<AbstractControl>();
  readonly enviado = input(false);
  readonly idMensaje = input('');
  // statusChanges cubre también los validadores asíncronos
  private readonly estado = toSignal(
    toObservable(this.control).pipe(switchMap((c) => c.statusChanges.pipe(startWith(c.status)))),
  );
  readonly mensaje = computed(() => {
    this.estado();                          // dependencia explícita para recalcular
    const c = this.control();
    if (!c.invalid || (!c.touched && !this.enviado())) return null;
    return primerMensaje(c.errors);
  });
}
// Uso: <app-errores [control]="form.controls.email" [enviado]="enviado()" idMensaje="err-email" />

5.9.6 Mapear errores del backend a los controles

La validación de cliente es una comodidad; la que manda es la del servidor. Cuando NestJS rechaza una petición hay que devolver esos errores al campo concreto, no a un cartel genérico.

api/src/main.ts · NestJS devolviendo errores por campo
app.useGlobalPipes(new ValidationPipe({
  whitelist: true, forbidNonWhitelisted: true, transform: true,
  // Por defecto Nest devuelve { message: string[] }, que no dice a qué campo
  // pertenece cada mensaje. Con exceptionFactory devolvemos un mapa campo → mensajes.
  exceptionFactory: (errores: ValidationError[]) => {
    const campos: Record<string, string[]> = {};
    const recorrer = (lista: ValidationError[], prefijo = '') => {
      for (const e of lista) {
        const ruta = prefijo ? `${prefijo}.${e.property}` : e.property;
        if (e.constraints) campos[ruta] = Object.values(e.constraints);
        if (e.children?.length) recorrer(e.children, ruta);
      }
    };
    recorrer(errores);
    return new BadRequestException({
      statusCode: 400, error: 'ValidationFailed',
      message: 'Los datos enviados no son válidos',
      campos,   // { "titulo": ["titulo no puede estar vacío"], "plazo.hasta": [...] }
    });
  },
}));
src/app/shared/aplicar-errores-servidor.ts
/** Traslada los errores 400/422 de la API a los controles. Devuelve los
 *  mensajes que no han podido asignarse a ningún campo. */
export function aplicarErroresServidor(form: FormGroup, err: HttpErrorResponse): string[] {
  if (err.status !== 400 && err.status !== 422) {
    return ['No se ha podido guardar. Inténtalo de nuevo en unos segundos.'];
  }
  const cuerpo = err.error as { campos?: Record<string, string[]>; message?: string };
  const huerfanos: string[] = [];
  for (const [ruta, mensajes] of Object.entries(cuerpo.campos ?? {})) {
    const control = form.get(ruta);      // admite rutas: 'plazo.hasta', 'etiquetas.0'
    if (!control) { huerfanos.push(...mensajes); continue; }
    control.setErrors({ ...(control.errors ?? {}), servidor: mensajes[0] });  // fusionar
    control.markAsTouched();             // para que el mensaje se vea de inmediato
  }
  if (!cuerpo.campos && cuerpo.message) huerfanos.push(cuerpo.message);
  return huerfanos;
}
El error del servidor desaparece al escribir Es el comportamiento deseado: en cuanto el usuario cambia el valor, el control ejecuta sus validadores y el error servidor —que no pone ningún validador— se borra. Si necesitas que persista, guárdalo en una señal aparte y bórralo tú al detectar el primer cambio. No intentes conservarlo dentro de errors: lucharás contra el ciclo de validación.

5.9.7 Ejemplo de producción: alta de tarea con etiquetas dinámicas

src/app/tareas/crear-tarea.component.ts
@Component({
  selector: 'app-crear-tarea',
  imports: [ReactiveFormsModule, ErroresComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './crear-tarea.component.html',
})
export class CrearTareaComponent {
  private readonly fb = inject(NonNullableFormBuilder);
  private readonly api = inject(TareasApi);
  private readonly host = inject(ElementRef<HTMLElement>);
  readonly enviando = signal(false);
  readonly enviado = signal(false);
  readonly erroresGenerales = signal<readonly string[]>([]);
  readonly form = this.fb.group({
    titulo: this.fb.control('', {
      validators: [Validators.required, Validators.maxLength(120)],
      asyncValidators: [(c) =>               // el título debe ser único en el proyecto
        timer(400).pipe(
          switchMap(() => this.api.tituloDisponible(c.value)),
          map((libre) => (libre ? null : { tituloEnUso: true })),
          catchError(() => of(null)),
          first(),
        ),
      ],
      updateOn: 'blur',        // una consulta al salir del campo, no por tecla
    }),
    descripcion: this.fb.control(''),
    prioridad: this.fb.control<'baja' | 'media' | 'alta'>('media'),
    etiquetas: this.fb.array<FormControl<string>>([]),
    plazo: this.fb.group(
      { desde: this.fb.control(''), hasta: this.fb.control('') },
      { validators: [rangoFechas('desde', 'hasta')] },
    ),
  });
  get etiquetas(): FormArray<FormControl<string>> { return this.form.controls.etiquetas; }
  readonly puedeAnadirEtiqueta = computed(() => this.etiquetas.length < 8);
  anadirEtiqueta(): void {
    if (this.etiquetas.length >= 8) return;
    this.etiquetas.push(this.fb.control('',
      [Validators.required, Validators.maxLength(24), sinEspacios()]));
  }
  quitarEtiqueta(i: number): void { this.etiquetas.removeAt(i); this.etiquetas.markAsDirty(); }
  enviar(): void {
    this.enviado.set(true);
    this.erroresGenerales.set([]);
    if (this.form.pending) return;          // aún se valida contra el servidor
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      this.enfocarPrimerError();
      return;
    }
    this.enviando.set(true);
    // getRawValue: incluye los deshabilitados y devuelve el tipo completo
    this.api.crear(this.form.getRawValue()).subscribe({
      next: () => {
        this.enviando.set(false);
        this.enviado.set(false);
        this.etiquetas.clear();
        this.form.reset({ prioridad: 'media' });   // limpio Y pristine
      },
      error: (err: HttpErrorResponse) => {
        this.enviando.set(false);
        this.erroresGenerales.set(aplicarErroresServidor(this.form, err));
        this.enfocarPrimerError();
      },
    });
  }
  private enfocarPrimerError(): void {
    // queueMicrotask: los mensajes y las clases ng-invalid deben estar ya en el DOM
    queueMicrotask(() => {
      const el = this.host.nativeElement.querySelector<HTMLElement>(
        'input.ng-invalid, select.ng-invalid, textarea.ng-invalid');
      el?.focus({ preventScroll: true });
      el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
    });
  }
}
src/app/tareas/crear-tarea.component.html
<form [formGroup]="form" (ngSubmit)="enviar()" novalidate>
  @if (erroresGenerales().length) {
    <div class="callout danger" role="alert" tabindex="-1">
      <b>No se ha podido guardar</b>
      <ul>@for (e of erroresGenerales(); track e) { <li>{{ e }}</li> }</ul>
    </div>
  }
  <fieldset>
    <legend>Datos de la tarea</legend>
    <label for="titulo">Título</label>
    <input id="titulo" formControlName="titulo" required maxlength="120"
           aria-describedby="ayuda-titulo err-titulo"
           [attr.aria-invalid]="form.controls.titulo.invalid && form.controls.titulo.touched">
    <p id="ayuda-titulo" class="small muted">Debe ser único dentro del proyecto.</p>
    @if (form.controls.titulo.pending) {
      <p class="small muted" aria-live="polite">Comprobando disponibilidad…</p>
    }
    <app-errores [control]="form.controls.titulo" [enviado]="enviado()" idMensaje="err-titulo" />
    <label for="prioridad">Prioridad</label>
    <select id="prioridad" formControlName="prioridad">
      <option value="baja">Baja</option><option value="media">Media</option>
      <option value="alta">Alta</option>
    </select>
  </fieldset>
  <fieldset formGroupName="plazo">
    <legend>Plazo</legend>
    <label for="desde">Desde</label>
    <input id="desde" type="date" formControlName="desde">
    <label for="hasta">Hasta</label>
    <input id="hasta" type="date" formControlName="hasta" aria-describedby="err-plazo"
           [attr.aria-invalid]="form.controls.plazo.hasError('rangoFechas')">
    @if (form.controls.plazo.hasError('rangoFechas') && form.controls.plazo.touched) {
      <p id="err-plazo" class="error" role="alert">La fecha final debe ser posterior a la inicial.</p>
    }
  </fieldset>
  <fieldset formArrayName="etiquetas">
    <legend>Etiquetas ({{ etiquetas.length }}/8)</legend>
    @for (ctrl of etiquetas.controls; track ctrl; let i = $index) {
      <div class="fila-etiqueta">
        <label [attr.for]="'etq-' + i" class="small">Etiqueta {{ i + 1 }}</label>
        <input [id]="'etq-' + i" [formControlName]="i" placeholder="backend">
        <button type="button" (click)="quitarEtiqueta(i)"
                [attr.aria-label]="'Quitar la etiqueta ' + (i + 1)">Quitar</button>
        <app-errores [control]="ctrl" [enviado]="enviado()" />
      </div>
    } @empty {
      <p class="muted small">Sin etiquetas todavía.</p>
    }
    <button type="button" (click)="anadirEtiqueta()" [disabled]="!puedeAnadirEtiqueta()">
      Añadir etiqueta</button>
  </fieldset>
  <button type="submit" [disabled]="enviando() || form.pending">
    {{ enviando() ? 'Guardando…' : 'Crear tarea' }}</button>
</form>
Detalles del ejemplo que conviene no pasar por alto

track ctrl en el @for del FormArray: se sigue la identidad del objeto FormControl, no el índice, así que al eliminar la etiqueta 2 Angular quita el nodo correcto en lugar de reescribir todos los inputs a partir de esa posición, y el foco no salta. [formControlName]="i" dentro de un formArrayName: el nombre de un control de FormArray es su índice numérico. Y el botón de envío no se deshabilita por form.invalid: un botón deshabilitado no se puede pulsar, no recibe foco y no explica nada. Es mejor dejarlo activo y, al pulsarlo, marcar todo como touched y llevar el foco al primer error.

5.10 ControlValueAccessor: componentes de entrada propios

Un ControlValueAccessor (CVA) es el adaptador entre el modelo de formularios de Angular y un widget concreto. Angular trae uno para cada elemento nativo; cuando escribes un componente propio —un selector de etiquetas, un input de moneda, una valoración con estrellas— tienes que aportar el tuyo. Es literalmente el patrón Adapter del catálogo GoF.

   FormControl                    ControlValueAccessor                 DOM
   setValue(v)   ── writeValue(v) ──────────────────────────► pintar el widget
   valor nuevo   ◄─ onChange(v)   ◄────────────────────────── el usuario edita
   markAsTouched ◄─ onTouched()   ◄────────────────────────── el usuario sale (blur)
   disable()     ── setDisabledState(true) ─────────────────► atenuar / bloquear
src/app/shared/tag-input.component.ts
@Component({
  selector: 'app-tag-input',
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    // forwardRef es obligatorio: la clase aún no está definida cuando se evalúa
    // el array de providers del decorador. multi: true, porque son tokens múltiples.
    { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TagInputComponent), multi: true },
    { provide: NG_VALIDATORS,     useExisting: forwardRef(() => TagInputComponent), multi: true },
  ],
  template: `
    <div class="tags" [class.disabled]="deshabilitado()">
      @for (t of etiquetas(); track t) {
        <span class="chip">{{ t }}
          <button type="button" (click)="quitar(t)" [disabled]="deshabilitado()"
                  [attr.aria-label]="'Quitar ' + t">&times;</button></span>
      }
      <input #campo [disabled]="deshabilitado()" aria-label="Añadir etiqueta"
             (keydown.enter)="anadir(campo); $event.preventDefault()" (blur)="alSalir()">
    </div>`,
})
export class TagInputComponent implements ControlValueAccessor, Validator {
  readonly etiquetas = signal<readonly string[]>([]);
  readonly deshabilitado = signal(false);
  private readonly maximo = 8;
  // Callbacks que Angular nos entrega. Se inicializan a funciones vacías para
  // que el componente funcione también fuera de un formulario.
  private alCambiar: (v: readonly string[]) => void = () => {};
  private alTocar: () => void = () => {};
  /** Modelo → vista. La llama Angular en setValue, patchValue, reset y al iniciar.
   *  NUNCA llames aquí a alCambiar(): provocarías un bucle modelo↔vista. */
  writeValue(valor: readonly string[] | null): void { this.etiquetas.set(valor ?? []); }
  registerOnChange(fn: (v: readonly string[]) => void): void { this.alCambiar = fn; }
  registerOnTouched(fn: () => void): void { this.alTocar = fn; }
  /** Opcional en la interfaz, imprescindible en la práctica: sin esto,
   *  form.disable() no tiene ningún efecto visible sobre el widget. */
  setDisabledState(d: boolean): void { this.deshabilitado.set(d); }
  /** NG_VALIDATORS: la validación propia viaja con el componente, así que
   *  quien lo use no tiene que acordarse de añadir un validador externo. */
  validate(control: AbstractControl): ValidationErrors | null {
    const v = (control.value ?? []) as readonly string[];
    if (v.length > this.maximo) return { demasiadasEtiquetas: { maximo: this.maximo, actual: v.length } };
    return new Set(v).size !== v.length ? { etiquetasDuplicadas: true } : null;
  }
  anadir(campo: HTMLInputElement): void {
    const texto = campo.value.trim().toLowerCase();
    if (!texto || this.etiquetas().includes(texto)) { campo.value = ''; return; }
    const siguiente = [...this.etiquetas(), texto];   // nueva referencia: OnPush
    this.etiquetas.set(siguiente);
    this.alCambiar(siguiente);      // ← esto es lo que actualiza el FormControl
    campo.value = '';
  }
  quitar(t: string): void {
    const siguiente = this.etiquetas().filter((x) => x !== t);
    this.etiquetas.set(siguiente);
    this.alCambiar(siguiente);
  }
  alSalir(): void { this.alTocar(); }   // sin esto, el control nunca llega a 'touched'
}
uso-cva.component.html · se usa como un input nativo
<form [formGroup]="form"><app-tag-input formControlName="etiquetas" /></form>
<app-tag-input [(ngModel)]="etiquetas" name="etiquetas" />    <!-- template-driven -->
<app-tag-input />                                             <!-- suelto, sin formulario -->
MétodoDirecciónQué debe hacerError habitual
writeValue(v)modelo → vistaActualizar el estado interno del widgetLlamar a onChange desde dentro: bucle infinito
registerOnChange(fn)Guardar fn e invocarla en cada edición del usuarioInvocarla también en cambios programáticos
registerOnTouched(fn)Guardar fn e invocarla en el blurNo invocarla nunca: el control jamás queda touched
setDisabledState(b)modelo → vistaReflejar el estado deshabilitadoNo implementarla: form.disable() no hace nada visible
validate(c)vista → modeloAportar errores propios vía NG_VALIDATORSOlvidar multi: true
multi: true no es opcional NG_VALUE_ACCESSOR y NG_VALIDATORS son multi-provider tokens: Angular espera un array. Si lo omites sustituyes toda la colección y romperás la validación del resto del formulario con un error críptico. Y si un elemento tiene dos accesores (tu directiva y el accesor nativo del input), Angular lanza More than one custom value accessor matches form control.

5.11 Formularios template-driven

suscripcion.component.ts
@Component({
  selector: 'app-suscripcion',
  imports: [FormsModule],          // ← FormsModule, no ReactiveFormsModule
  template: `
    <form #f="ngForm" (ngSubmit)="enviar(f)" novalidate>
      <label for="email">Correo</label>
      <input id="email" name="email" type="email"
             [(ngModel)]="modelo.email" #email="ngModel" required email>
      @if (email.invalid && (email.touched || f.submitted)) {
        <p class="error" role="alert">
          @if (email.hasError('required')) { El correo es obligatorio. }
          @if (email.hasError('email')) { El formato no es válido. }
        </p>
      }
      <!-- ngModelGroup crea un FormGroup anidado sin escribir nada en la clase -->
      <fieldset ngModelGroup="preferencias">
        <legend>Preferencias</legend>
        <label><input type="checkbox" name="novedades" [(ngModel)]="modelo.novedades"> Novedades</label>
        <label><input type="checkbox" name="ofertas" [(ngModel)]="modelo.ofertas"> Ofertas</label>
      </fieldset>
      <button type="submit">Suscribirme</button>
    </form>`,
})
export class SuscripcionComponent {
  modelo = { email: '', novedades: true, ofertas: false };
  enviar(f: NgForm): void {
    if (f.invalid) { f.control.markAllAsTouched(); return; }
    console.log(f.value);   // { email: '...', preferencias: { novedades: true, ofertas: false } }
  }
}

5.12 Accesibilidad en formularios

Un formulario inaccesible no es un formulario «mejorable»: es un formulario que una parte de tus usuarios no puede rellenar. En España y en la Unión Europea, además, hay obligaciones legales para el sector público y, progresivamente, para el privado. Estos son los mínimos no negociables.

campo.component.htmlINCORRECTO
<!-- 1. El placeholder NO es una etiqueta: desaparece al
     escribir y muchos lectores no lo anuncian -->
<input formControlName="email" placeholder="Email">
<!-- 2. Etiqueta suelta, sin asociar: pulsar sobre ella no
     enfoca el campo y el lector no la relaciona -->
<span>Contraseña</span>
<input type="password" formControlName="password">
<!-- 3. El error solo se comunica con COLOR, no está
     asociado al campo y no se anuncia -->
<p style="color:red">Formato incorrecto</p>
<!-- 4. Grupo de radios sin agrupación semántica -->
<input type="radio" name="p" value="baja"> Baja
<input type="radio" name="p" value="alta"> Alta
<!-- 5. Un div que finge ser botón: no recibe foco ni
     responde a Enter o Espacio -->
<div class="btn" (click)="enviar()">Guardar</div>
campo.component.htmlCORRECTO
<!-- 1 y 2. label asociada por for/id. El placeholder, si
     se usa, es una AYUDA, nunca la etiqueta -->
<label for="email">Correo electrónico</label>
<input id="email" type="email" formControlName="email"
       autocomplete="email" required
       aria-describedby="ayuda-email err-email"
       [attr.aria-invalid]="hayError('email')">
<p id="ayuda-email" class="small muted">Lo usaremos para avisarte.</p>
<!-- 3. Error asociado con aria-describedby y anunciado -->
@if (hayError('email')) {
  <p id="err-email" class="error" role="alert">
    El formato del correo no es válido.</p>
}
<!-- 4. fieldset + legend: el lector anuncia el grupo -->
<fieldset>
  <legend>Prioridad</legend>
  <label><input type="radio" formControlName="p" value="baja"> Baja</label>
  <label><input type="radio" formControlName="p" value="alta"> Alta</label>
</fieldset>
<!-- 5. Elemento nativo: foco, Enter, Espacio y rol gratis -->
<button type="submit">Guardar</button>
RequisitoCómo se implementaPor qué importa
Etiqueta asociada<label for="id"> o envolviendo el inputEl lector anuncia el nombre del campo al enfocarlo y se amplía el área de pulsación.
Estado inválido[attr.aria-invalid]Comunica el error sin depender del color, que no ven ni los lectores ni los usuarios con daltonismo.
Mensaje vinculadoaria-describedby="id-del-error"Al enfocar el campo, el lector lee también el motivo del fallo.
Anuncio del errorrole="alert" o aria-live="polite"El usuario se entera aunque no vuelva a enfocar el campo.
Agrupación<fieldset> + <legend>Da contexto a radios y checkboxes: «Prioridad, Baja, botón de radio, 1 de 2».
Campo obligatoriorequired nativo o aria-required="true"Se anuncia como obligatorio antes de que el usuario lo rellene mal.
Autocompletadoautocomplete="email", "new-password"Reduce mucho el esfuerzo, sobre todo con discapacidad motora.
Foco visibleNo elimines :focus-visible en el CSSSin él, navegar con teclado es imposible.
Orden de tabulaciónOrden natural del DOM; evita tabindex positivosUn tabindex="5" desordena toda la página.
Resumen de errores al enviarContenedor con role="alert" y tabindex="-1", y llevarle el focoEn formularios largos, el usuario sabe de inmediato cuántos errores hay y dónde.
accesibilidad.ts · llevar el foco al primer error
private enfocarPrimerInvalido(): void {
  const raiz = this.host.nativeElement as HTMLElement;
  // Solo elementos ENFOCABLES: un div con ng-invalid no sirve
  const invalido = raiz.querySelector<HTMLElement>(
    'input.ng-invalid, select.ng-invalid, textarea.ng-invalid, [tabindex].ng-invalid');
  if (!invalido) return;
  invalido.focus({ preventScroll: true });
  invalido.scrollIntoView({ block: 'center', behavior: 'smooth' });
}

5.13 Rendimiento en plantillas y formularios

El error de rendimiento número uno son las funciones en la plantilla, y es el más invisible porque en desarrollo con diez elementos no se nota. Recuerda el bloque de actualización de la sección 5.2: toda expresión de la plantilla se reevalúa en cada ciclo de detección de cambios.

lista.component.htmlINCORRECTO
<!-- 300 tareas × 3 llamadas por fila = 900 ejecuciones POR
     CICLO. Con detección por defecto, un mousemove dispara
     un ciclo: cientos de miles de llamadas por segundo. Y
     cada objeto literal es una referencia nueva, así que
     rompe OnPush en los hijos. -->
@for (t of tareas(); track t.id) {
  <app-fila [clase]="calcularClase(t)"
            [estilo]="{ color: t.color, width: ancho() + 'px' }"
            [texto]="t.titulo | slice:0:30" />
}
<p>Total: {{ calcularTotal() }} €</p>
<p>Pendientes: {{ tareas().filter(esPendiente).length }}</p>
lista.component.tsCORRECTO
// Se calcula UNA vez por cambio real de los datos, no una
// vez por ciclo. Las referencias son estables.
readonly filas = computed(() => this.tareas().map((t) => ({
  id: t.id, clase: this.calcularClase(t),
  estilo: { color: t.color }, texto: t.titulo.slice(0, 30),
})));
readonly total = computed(() => this.tareas().reduce((s, t) => s + t.importe, 0));
readonly pendientes = computed(() =>
  this.tareas().filter((t) => t.estado === 'pendiente').length);
// plantilla:
// @for (f of filas(); track f.id) {
//   <app-fila [clase]="f.clase" [estilo]="f.estilo" [texto]="f.texto" />
// }
MedidaEfectoCuándo aplicarla
ChangeDetectionStrategy.OnPushEl componente solo se comprueba si cambian sus entradas por referencia, si emite un evento, si un AsyncPipe recibe un valor o si cambia una señal que leeSiempre, en todos los componentes nuevos.
Señales computadas en vez de métodosElimina el recálculo por ciclo y estabiliza las referenciasToda expresión que no sea un simple acceso a propiedad.
track por identificadorConvierte recreaciones completas en movimientos de nodosCualquier lista que se filtre, ordene o refresque.
updateOn: 'blur'Reduce por 10-50 el número de ciclos de validación y de emisionesFormularios con más de 10 campos o con validadores costosos.
@deferSaca peso del bundle inicial y retrasa el trabajo de renderizadoGráficos, editores enriquecidos, mapas, contenido bajo el pliegue.
Virtual scroll del CDKRenderiza solo las filas visibles: el DOM deja de crecer con los datosListas de más de ~200 filas.
Modo zonelessElimina zone.js: la detección la disparan solo señales y eventosProyectos ya migrados a señales; comprueba el estado de la API en tu versión.
Evitar pipes impurosSuprime N ejecuciones por cicloSiempre que exista una alternativa con señal computada.
OnPush y formularios reactivos Conviven bien, con un matiz: si cambias el estado de un control desde código (markAllAsTouched(), setErrors()) en un contexto OnPush que no pasa por un evento de la plantilla, la vista puede no refrescarse. Si tus mensajes de error salen de un computed que depende de una señal que tú actualizas, el problema no aparece. Si no, la salida es un ChangeDetectorRef.markForCheck() puntual, y merece la pena anotar por qué.
lista-virtual.component.html · CDK virtual scroll
<cdk-virtual-scroll-viewport itemSize="48" style="height:480px">
  <div *cdkVirtualFor="let t of tareas; trackBy: porId" class="fila">{{ t.titulo }}</div>
</cdk-virtual-scroll-viewport>
<!-- cdkVirtualFor sigue siendo una directiva estructural; no hay bloque @for
     equivalente. Se combina sin problema con @if y @defer. -->

5.14 Seguridad en plantillas

Angular parte de un principio sano: todo valor que entra en el DOM es sospechoso hasta que se demuestre lo contrario. Por eso define contextos de seguridad y sanea automáticamente los valores enlazados a ellos.

ContextoDónde apareceQué hace Angular
Texto{{ }}Lo inserta como texto: el HTML nunca se interpreta.
HTML[innerHTML]Sanea: elimina <script>, manejadores on* y atributos peligrosos.
Estilo[style]Sanea el valor CSS.
URL[href], [src]Neutraliza esquemas peligrosos como javascript:.
URL de recurso[src] de iframeNo se puede sanear: exige bypassSecurityTrustResourceUrl explícito.
comentario.component.tsINCORRECTO
// 1) Construir HTML concatenando datos del usuario
get html(): string {
  return `<p class="c">${this.comentario.texto}</p>`;
}
// 2) Y encima desactivar la sanitización "porque no se veía
//    el formato". Esto es XSS almacenado: cualquier usuario
//    puede robar la sesión de otro.
get seguro(): SafeHtml {
  return this.sanitizer.bypassSecurityTrustHtml(this.html);
}
// plantilla: <div [innerHTML]="seguro"></div>
comentario.component.tsCORRECTO
// Opción A (la mejor): NO generes HTML. Interpola el texto
// y aporta el formato con la plantilla y el CSS:
//   <p class="c">{{ comentario.texto }}</p>
// Opción B: si el texto es Markdown legítimo, conviértelo y
// SANEA el resultado en el SERVIDOR con una lista blanca
// antes de guardarlo. En el cliente, la sanitización de
// Angular actúa como segunda barrera:
//   <div [innerHTML]="htmlDelServidor"></div>
// bypassSecurityTrust* SOLO para constantes tuyas, jamás
// para nada que provenga de una petición o de un usuario.
readonly videoUrl = this.sanitizer
  .bypassSecurityTrustResourceUrl('https://player.vimeo.com/video/12345');

5.15 Errores comunes y cómo solucionarlos

ErrorCausa realSolución
Cannot find control with name: 'x'El formControlName no existe en el FormGroup, hay una errata, o el control se crea después de renderizarComprobar el nombre; construir el formulario en el campo de la clase, no en ngOnInit; envolver con @if (form) si es condicional
Cannot find control with path: 'a -> b'Falta el formGroupName o el formArrayName del contenedor intermedioAñadir formGroupName="a" en el elemento que envuelve a b
ngModel cannot be used to register form controls with a parent formGroup directiveSe mezclan las dos APIs en el mismo elemento o formularioElegir una; si el ngModel es ajeno al modelo, [ngModelOptions]="{ standalone: true }"
ExpressionChangedAfterItHasBeenCheckedErrorUna expresión devuelve un valor distinto en la segunda comprobación del modo desarrollo: método que crea objetos, Math.random(), o un cambio de estado en ngAfterViewInitSustituir el método por una señal computada; mover el cambio a ngOnInit; en último recurso afterNextRender()
NG0955: track expression resulted in duplicated keysDos elementos producen la misma clave de trackInvestigar el duplicado; si son primitivos que legítimamente se repiten, track $index
La lista parpadea o pierde el foco al refrescartrack por índice o por referencia sobre datos que llegan nuevos en cada respuesta HTTPtrack item.id
El formulario nunca se puede enviarUn validador asíncrono que emite pero no completa: el control se queda en PENDINGAñadir first() o take(1), y catchError para el fallo de red
form.value no incluye un campoEse control está deshabilitadogetRawValue(), o marcar el campo como readonly en lugar de deshabilitarlo
Un campo tipado devuelve null tras reset()El control no es nonNullableNonNullableFormBuilder, { nonNullable: true } o reset(valorInicial)
Los validadores nuevos no hacen efectoSe llamó a setValidators() sin updateValueAndValidity()Añadirlo siempre justo después, con { emitEvent: false }
El navegador se congela al editar un campoBucle: una suscripción a valueChanges escribe en otro control sin emitEvent: false{ emitEvent: false } en toda escritura hecha desde una suscripción
More than one custom value accessor matches form controlDos ControlValueAccessor compiten por el mismo elemento (una directiva propia con selector demasiado amplio)Restringir el selector o dejar de proveer NG_VALUE_ACCESSOR en ella
Un componente propio no se deshabilita con form.disable()El CVA no implementa setDisabledStateImplementarlo y reflejar el estado en el widget
Un control propio nunca llega a touchedEl CVA no invoca el callback de registerOnTouchedLlamarlo en el blur del elemento interno
NG01203: No value accessor for form controlformControlName sobre un elemento que no es un control nativo ni implementa CVAImplementar ControlValueAccessor o usar un elemento de formulario nativo
El contenido de un @defer sigue en el bundle inicialEl componente se usa también fuera del bloque, no es standalone, o se referencia desde la claseUsarlo solo dentro del @defer, hacerlo standalone y quitar las referencias en TypeScript
Tres peticiones HTTP idénticas al pintar la vistaTres | async sobre el mismo observable frío@if (x$ | async; as x), @let, shareReplay(1) o toSignal
El texto de un error del servidor desaparece al escribirNormal: el ciclo de validación reconstruye errorsGuardar el mensaje del servidor en una señal aparte si debe persistir

5.16 Buenas y malas prácticas

Haz esto

  • Una sola llamada a método por evento en la plantilla. La lógica, en la clase.
  • track por identificador estable en toda lista que se filtre, ordene o refresque.
  • Señales computadas para todo lo que no sea un acceso directo a una propiedad.
  • Formularios reactivos con NonNullableFormBuilder y tipos derivados con ReturnType<typeof form.getRawValue>.
  • getRawValue() al enviar siempre que exista algún control deshabilitado.
  • Errores solo cuando touched o tras enviar, y markAllAsTouched() en el envío.
  • Un componente de errores y un diccionario de mensajes compartidos por toda la aplicación.
  • Validadores como funciones puras exportadas, probadas sin TestBed.
  • updateOn: 'blur' en formularios grandes y en todo control con validación asíncrona.
  • label, aria-invalid, aria-describedby y foco al primer error en cada formulario, sin excepción.
  • @defer con prefetch para el contenido pesado que el usuario acabará abriendo.
  • OnPush en todos los componentes nuevos.

Evita esto

  • Llamadas a métodos en interpolaciones y property bindings. Se ejecutan en cada ciclo.
  • Objetos y arrays literales en la plantilla: referencia nueva en cada ciclo, OnPush roto.
  • track $index por inercia en listas que se reordenan o se filtran.
  • Pipes impuros para filtrar u ordenar listas.
  • Mezclar ngModel y formControlName en el mismo formulario.
  • Deshabilitar el botón de envío con form.invalid: el usuario no sabe qué falta.
  • Cascadas de @if por cada clave de error repetidas campo a campo.
  • Validadores asíncronos sin debounce, sin catchError y sin first().
  • setErrors() sin fusionar los errores existentes.
  • bypassSecurityTrustHtml sobre datos de la API o del usuario.
  • Placeholder en lugar de label.
  • $any() para silenciar el comprobador de plantillas en vez de arreglar el tipo.

5.17 Preguntas frecuentes

¿Por qué track es obligatorio si trackBy era opcional?
Porque su ausencia era una de las principales causas de problemas de rendimiento y de pérdida de estado en aplicaciones reales, y era invisible: nada avisaba. Sin trackBy, *ngFor usaba la identidad por referencia; con datos que llegan de HTTP, cada respuesta produce objetos nuevos y Angular destruía y recreaba la lista entera. Al hacerlo obligatorio, una decisión implícita y casi siempre equivocada pasa a ser una decisión consciente que tomas al escribir el bucle.
¿Puedo seguir usando *ngIf y *ngFor?
Sí: las directivas estructurales siguen soportadas y no hay anuncio de eliminación inminente. Dicho esto, el control flow con bloques es la dirección oficial del framework, es más rápido, pesa menos y da mejores mensajes de error. Para código nuevo, usa bloques. Para código existente, ejecuta ng generate @angular/core:control-flow y revisa el diff, prestando atención a los track que la herramienta haya resuelto como $index.
¿Cuándo uso @defer en lugar de una ruta con carga perezosa?
Son complementarios y actúan a escalas distintas. La carga perezosa de rutas divide la aplicación por pantallas: es lo primero que hay que hacer y afecta al arranque. @defer divide dentro de una pantalla, para que un panel con un motor de gráficos de 300 KB no lastre la carga de la página de detalle. Regla práctica: rutas perezosas siempre; @defer cuando midas que un componente concreto pesa mucho y no es visible ni necesario de inmediato.
¿Formularios reactivos o template-driven en un proyecto nuevo?
Reactivos, salvo en formularios triviales. El motivo principal no es la potencia, sino la testabilidad y el tipado: el modelo existe como objeto de TypeScript antes de renderizar nada, así que puedes escribir tests unitarios de la validación sin TestBed, sin fixture y sin esperas asíncronas. En un formulario con validación cruzada, campos condicionales y errores del servidor, la diferencia de mantenibilidad es enorme.
¿Por qué mi form.value no tiene todos los campos?
Porque alguno está deshabilitado. Angular excluye deliberadamente los controles disabled del valor del grupo y de la validación, y por eso TypeScript tipa value como Partial<T>. Usa getRawValue() cuando necesites el objeto completo. Si un campo debe verse pero no editarse y aun así viajar al servidor, no lo deshabilites: ponle readonly en el input.
¿Cómo evito que un validador asíncrono dispare una petición por cada tecla?
Tienes dos palancas y conviene usar ambas. La primera es updateOn: 'blur' en el control, que hace que la validación se ejecute una sola vez, al salir del campo. La segunda es un debounce dentro del propio validador (timer(400).pipe(switchMap(...))), que además cancela la comprobación anterior si el valor vuelve a cambiar. No olvides first() para que el observable complete y catchError para que un fallo de red no deje el formulario colgado en PENDING.
¿Dónde pongo un validador que compara dos campos?
En el FormGroup que contiene a ambos, mediante la opción validators del segundo argumento. El error aparece entonces en form.errors, no en los controles, y en la plantilla se comprueba con form.hasError('miError'). Si además quieres pintar de rojo un campo concreto, tu validador de grupo puede añadir un error al control con setErrors, fusionando siempre con los errores existentes.
¿Merece la pena implementar un ControlValueAccessor?
Si el componente representa el valor de un campo, sin discusión: te da gratis formControlName, ngModel, los estados touched/dirty, las clases ng-*, la validación integrada, disable() y reset(). Con entradas y salidas tendrías que reimplementar todo eso en cada uso. Si el componente no es un campo (un diálogo, una tabla), no lo fuerces: usa entradas y salidas normales.
¿Puedo usar señales dentro de un formulario reactivo?
Sí, y es lo recomendable. Los formularios reactivos siguen basados en RxJS, pero el puente es inmediato: toSignal(control.valueChanges, { initialValue: control.value }) convierte cualquier flujo del formulario en una señal, y a partir de ahí puedes derivar con computed(). Angular trabaja además en una API de formularios basada en señales; hasta que sea estable, este puente cubre todos los casos prácticos.
¿Qué diferencia hay entre @let y una señal computada?
@let es una variable local de la plantilla: solo existe ahí, no se puede probar aisladamente y se recalcula con la vista. Un computed() vive en la clase, está memoizado, es reutilizable y comprobable con un test unitario. Usa @let para lo puramente presentacional y de alcance local (evitar repetir un | async, dar nombre a una expresión larga dentro de un @for). Para lógica derivada de verdad, computed().
¿Por qué el error del servidor desaparece cuando el usuario corrige el campo?
Porque al cambiar el valor el control ejecuta de nuevo sus validadores y reconstruye por completo su objeto errors. El error que inyectaste con setErrors no lo produce ningún validador, así que no se regenera. Normalmente es lo deseado: el usuario ha cambiado el dato, así que la afirmación del servidor sobre el dato anterior ya no aplica. Si necesitas que persista hasta un nuevo envío, guárdalo fuera del formulario, en una señal.
¿Es mala práctica deshabilitar el botón de envío mientras el formulario es inválido?
Sí, en la mayoría de los casos. Un botón deshabilitado no recibe foco, no se anuncia bien a los lectores de pantalla y, sobre todo, no explica qué falta: el usuario se queda buscando el campo culpable. La alternativa profesional es dejarlo activo y, al pulsarlo, ejecutar markAllAsTouched(), mostrar un resumen de errores con role="alert" y llevar el foco al primer campo inválido. Sí tiene sentido deshabilitarlo mientras la petición está en curso, para evitar envíos duplicados.

5.18 Ejercicios

Nivel 1 · básico

5.1 Dada una lista de 500 usuarios que se refresca cada 15 segundos desde la API, donde cada fila contiene un <input> para editar el nombre, escribe el @for correcto. Justifica en dos frases por qué track $index sería un error y qué le pasaría exactamente al usuario que estuviera escribiendo cuando llega el refresco.

5.2 Convierte a control flow nativo y explica qué se gana: <div *ngIf="cargando; else contenido">…</div> con <ng-template #contenido><p *ngFor="let x of xs">{{ x }}</p></ng-template>.

5.3 Escribe un formulario reactivo con tres campos (nombre obligatorio de 3 a 60 caracteres, email válido y edad entre 18 y 120) usando NonNullableFormBuilder. Muestra los errores solo cuando el campo esté touched.

5.4 Enumera cuatro elementos o atributos en los que [attr.x] es obligatorio y explica el motivo concreto de cada uno.

Nivel 2 · intermedio

5.5 Implementa un validador de grupo rangoNumerico(min, max) que compruebe que el valor del campo min es menor o igual que el de max, marque además el control max con un error propio sin borrar sus otros errores, y escribe tres tests unitarios sin TestBed.

5.6 Construye un componente <app-errores> que reciba un AbstractControl, consulte un diccionario de mensajes y muestre el primer error aplicable con role="alert". Añade una entrada enviado para forzar la visualización tras un intento de envío.

5.7 Escribe un @defer para un panel de estadísticas que cargue al entrar en el viewport, con prefetch en idle, un esqueleto con minimum 400ms, un spinner que solo aparezca si tarda más de 150 ms y un bloque de error con botón de reintento. Comprueba con ng build --stats-json que el componente está realmente en un chunk aparte.

5.8 Dado un formulario con un FormArray de líneas de pedido (producto, cantidad, precio), añade una señal computada con el total y un validador de array que impida más de 20 líneas y exija al menos una.

5.9 Crea una tabla genérica reutilizable que reciba las columnas por ng-template con contexto tipado (usa ngTemplateContextGuard) y compruébalo con strictTemplates activo.

Nivel 3 · avanzado

5.10 Formulario de registro completo: email (obligatorio, formato válido y validación asíncrona de disponibilidad contra la API con debounce), contraseña con reglas de complejidad, confirmación con validación cruzada, aceptación de condiciones con requiredTrue, mapeo de los errores 400 de NestJS a los controles y foco automático en el primer campo inválido al enviar.

5.11 Implementa un ControlValueAccessor para un input de moneda que muestre 1.234,56 € mientras no tiene el foco y 1234.56 mientras se edita, que trabaje internamente en céntimos (enteros, para no perder precisión), implemente setDisabledState y aporte su propia validación de máximo mediante NG_VALIDATORS.

5.12 Construye un motor de formularios dinámicos que reciba un esquema JSON del servidor, genere el FormGroup, resuelva el componente de cada campo con NgComponentOutlet desde un registro cerrado y soporte campos condicionales del tipo «muestra periodicidad solo si tipo === 'recurrente'».

5.13 Toma una lista de 5.000 elementos con filtro en vivo y ordenación por columna. Mide con Angular DevTools el tiempo de detección de cambios. Aplica OnPush, señales computadas, track por identificador y virtual scroll del CDK, y documenta la mejora en un antes/después.

Solución comentada · 5.10 Formulario de registro completo
registro.component.ts
/** Validador de CONTROL: complejidad de la contraseña. */
const complejidad: ValidatorFn = (c: AbstractControl): ValidationErrors | null => {
  const v = String(c.value ?? '');
  if (!v) return null;                        // 'required' se encarga del vacío
  const fallos: string[] = [];
  if (!/[a-z]/.test(v))   fallos.push('una minúscula');
  if (!/[A-Z]/.test(v))   fallos.push('una mayúscula');
  if (!/\d/.test(v))      fallos.push('un dígito');
  if (!/[^\w\s]/.test(v)) fallos.push('un símbolo');
  return fallos.length ? { complejidad: { falta: fallos } } : null;
};
/** Validador de GRUPO: la confirmación debe coincidir. */
const coinciden: ValidatorFn = (g: AbstractControl): ValidationErrors | null => {
  const p = g.get('password')?.value, c = g.get('confirmacion')?.value;
  if (!p || !c) return null;                  // aún no molestamos al usuario
  return p === c ? null : { noCoinciden: true };
};
export class RegistroComponent {
  private readonly fb = inject(NonNullableFormBuilder);
  private readonly api = inject(AuthApi);
  private readonly host = inject(ElementRef<HTMLElement>);
  readonly enviando = signal(false);
  readonly enviado = signal(false);
  readonly errorGeneral = signal<string | null>(null);
  readonly form = this.fb.group(
    {
      email: this.fb.control('', {
        validators: [Validators.required, Validators.email],
        // El async NO se ejecuta si los síncronos fallan: no gastamos
        // peticiones en direcciones con formato inválido.
        asyncValidators: [(c) =>
          timer(400).pipe(                                 // debounce
            switchMap(() => this.api.emailDisponible(c.value)),
            map((libre) => (libre ? null : { emailEnUso: true })),
            catchError(() => of(null)),                    // fallar en abierto
            first(),                                       // COMPLETA: si no, PENDING eterno
          ),
        ],
        updateOn: 'blur',      // una única consulta, al salir del campo
      }),
      password: this.fb.control('', [Validators.required, Validators.minLength(10), complejidad]),
      confirmacion: this.fb.control('', [Validators.required]),
      condiciones: this.fb.control(false, [Validators.requiredTrue]),
    },
    { validators: [coinciden] },   // validador CRUZADO, a nivel de grupo
  );
  mostrar(nombre: keyof typeof this.form.controls): boolean {
    const c = this.form.controls[nombre];
    return c.invalid && (c.touched || this.enviado());
  }
  enviar(): void {
    this.enviado.set(true);
    this.errorGeneral.set(null);
    // Si hay validación asíncrona en curso, esperar: enviar ahora sería
    // saltarse la comprobación de unicidad.
    if (this.form.pending) return;
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      this.enfocarPrimerInvalido();
      return;
    }
    this.enviando.set(true);
    const { email, password } = this.form.getRawValue();
    this.api.registrar({ email, password }).subscribe({
      next: () => { this.enviando.set(false); this.form.reset(); },
      error: (err: HttpErrorResponse) => {
        this.enviando.set(false);
        // El servidor manda: puede rechazar un email que el cliente creía libre
        // (condición de carrera entre la comprobación y el alta).
        const campos = (err.error?.campos ?? {}) as Record<string, string[]>;
        let asignado = false;
        for (const [ruta, mensajes] of Object.entries(campos)) {
          const ctrl = this.form.get(ruta);
          if (!ctrl) continue;
          ctrl.setErrors({ ...(ctrl.errors ?? {}), servidor: mensajes[0] });  // fusionar
          ctrl.markAsTouched();
          asignado = true;
        }
        if (!asignado) this.errorGeneral.set('No se ha podido completar el registro.');
        this.enfocarPrimerInvalido();
      },
    });
  }
  private enfocarPrimerInvalido(): void {
    queueMicrotask(() => {   // los mensajes y ng-invalid deben estar ya en el DOM
      const el = this.host.nativeElement.querySelector<HTMLElement>(
        'input.ng-invalid, select.ng-invalid, textarea.ng-invalid');
      el?.focus({ preventScroll: true });
      el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
    });
  }
}

Puntos clave. El validador asíncrono va en el control, no en el grupo, porque comprueba un único campo. El updateOn: 'blur' combinado con el timer(400) da dos capas de protección al backend. El first() es lo que garantiza que el control salga alguna vez de PENDING. El validador cruzado vive en el grupo, así que su error se lee con form.hasError('noCoinciden'), no en confirmacion.errors. Y el mapeo de errores del servidor fusiona con los errores existentes en lugar de reemplazarlos, porque setErrors sustituye el objeto completo.

Solución comentada · 5.11 ControlValueAccessor de moneda
moneda-input.component.ts
const ACCESOR   = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MonedaInputComponent), multi: true };
const VALIDADOR = { provide: NG_VALIDATORS,     useExisting: forwardRef(() => MonedaInputComponent), multi: true };
/** El valor del FormControl son CÉNTIMOS (entero). Trabajar en enteros evita
 *  los errores de coma flotante: 0.1 + 0.2 !== 0.3, pero 10 + 20 === 30. */
@Component({
  selector: 'app-moneda-input',
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [ACCESOR, VALIDADOR],
  template: `<input #campo inputmode="decimal" [value]="texto()" [disabled]="deshabilitado()"
                    (focus)="alEnfocar()" (input)="alEscribir(campo.value)" (blur)="alSalir()">`,
})
export class MonedaInputComponent implements ControlValueAccessor, Validator {
  readonly maximo = input<number | null>(null);      // máximo permitido, en céntimos
  private readonly centimos = signal<number | null>(null);
  readonly deshabilitado = signal(false);
  readonly texto = signal('');                        // crudo al editar, formateado si no
  private alCambiar: (v: number | null) => void = () => {};
  private alTocar: () => void = () => {};
  writeValue(valor: number | null): void {
    this.centimos.set(valor);
    this.texto.set(this.formatear(valor));   // sin alCambiar: no es edición del usuario
  }
  registerOnChange(fn: (v: number | null) => void): void { this.alCambiar = fn; }
  registerOnTouched(fn: () => void): void { this.alTocar = fn; }
  setDisabledState(d: boolean): void { this.deshabilitado.set(d); }
  validate(c: AbstractControl): ValidationErrors | null {
    const v = c.value as number | null;
    if (v === null) return null;
    if (!Number.isInteger(v)) return { monedaInvalida: true };
    const max = this.maximo();
    return max !== null && v > max ? { maximoSuperado: { max, actual: v } } : null;
  }
  alEnfocar(): void {
    const c = this.centimos();
    this.texto.set(c === null ? '' : (c / 100).toFixed(2));   // modo edición: 1234.56
  }
  alEscribir(bruto: string): void {
    const num = Number.parseFloat(bruto.replace(/\./g, '').replace(',', '.'));
    const c = Number.isFinite(num) ? Math.round(num * 100) : null;  // round: evita 1234.5599999
    this.centimos.set(c);
    this.alCambiar(c);                       // ← notifica al FormControl
  }
  alSalir(): void {
    this.texto.set(this.formatear(this.centimos()));          // modo lectura: 1.234,56 €
    this.alTocar();                          // sin esto, el control nunca es 'touched'
  }
  private formatear(centimos: number | null): string {
    if (centimos === null) return '';
    return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(centimos / 100);
  }
}

Puntos clave. El modelo son céntimos enteros: la aritmética con decimales en punto flotante es la causa clásica de descuadres de un céntimo en facturación. writeValue nunca llama a alCambiar: si lo hiciera, un setValue desde el código provocaría una notificación de cambio y, con ella, un bucle modelo–vista. setDisabledState está implementado, que es la omisión más frecuente en los CVA de proyectos reales. Y la validación de máximo viaja con el componente vía NG_VALIDATORS con multi: true, de modo que quien escriba <app-moneda-input formControlName="importe" [maximo]="1000000" /> no tiene que acordarse de añadir nada más.

5.19 Resumen del capítulo

  • La plantilla es un lenguaje compilado, no HTML. Genera un bloque de creación que se ejecuta una vez y uno de actualización que se ejecuta en cada ciclo: de ahí salen todas las reglas de rendimiento.
  • Sus restricciones son deliberadas. Sin asignaciones en expresiones, sin new, sin operadores bit a bit y sin acceso a globales, las expresiones son analizables, idempotentes, tipables y seguras.
  • [prop] enlaza propiedades del DOM; [attr.x], atributos. Necesitas attr. para ARIA, colspan, SVG y data-*, y para eliminar un atributo con null.
  • track es la identidad de cada elemento de una lista. Con un identificador estable Angular mueve nodos y conserva foco, estado y animaciones; con $index reescribe posiciones y el contenido puede acabar en la fila equivocada.
  • @defer lleva la carga perezosa al nivel de plantilla, con placeholder, loading, error y una batería de disparadores. Solo difiere dependencias standalone usadas exclusivamente dentro del bloque, y su interacción con SSR depende de la versión.
  • ng-template + ngTemplateOutlet convierte un componente rígido en una pieza reutilizable de verdad; NgComponentOutlet cubre el caso en que ni el tipo del componente se conoce al compilar.
  • Los formularios reactivos ponen el modelo en la clase: tipado, síncrono, testeable sin TestBed y preparado para validación cruzada, asíncrona y campos dinámicos. Los template-driven se reservan para lo trivial.
  • value excluye los controles deshabilitados; getRawValue() no. Es la causa número uno de datos que «desaparecen» al enviar.
  • Un error se muestra cuando el control es inválido y el usuario ya salió del campo o ya intentó enviar. markAllAsTouched() en el envío, un diccionario de mensajes y un componente de errores compartido.
  • Un validador asíncrono debe hacer debounce, capturar errores y completar. Sin first(), el control se queda en PENDING para siempre.
  • ControlValueAccessor es el adaptador entre tu widget y el modelo de formularios; olvidar registerOnTouched o setDisabledState produce fallos sutiles.
  • Accesibilidad y seguridad no son extras. label asociada, aria-invalid, aria-describedby, fieldset/legend y foco al primer error; y jamás HTML construido con datos del usuario.

5.20 Recursos adicionales

Siguiente paso Ya sabes construir vistas y formularios sólidos. El capítulo 6 conecta esas pantallas entre sí y con el servidor: enrutado, guardas, resolvers, HttpClient, interceptores y el manejo de errores de red de extremo a extremo.