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.
5.1 Qué vas a poder hacer al terminar
- Explicar en qué se convierte una plantilla al compilarse y por qué eso determina lo que puedes escribir dentro de ella.
- Elegir con criterio entre
[prop]y[attr.x], y saber cuándoattr.es obligatorio. - Usar
@if,@for,@switchy@defercon todas sus variantes, y justificar por quétrackes obligatorio y qué rompe si lo eliges mal. - Diseñar componentes con plantillas parametrizables mediante
ng-template,ngTemplateOutletcon contexto tipado yNgComponentOutlet. - Construir formularios reactivos tipados con
FormGroup,FormArrayyFormRecord, y entender por quévaluees parcial ygetRawValue()no. - Escribir validadores síncronos, cruzados y asíncronos correctos, y mostrar los errores con una estrategia escalable en lugar de una cascada de
@if. - Mapear los errores 400/422 que devuelve NestJS a los controles concretos del formulario.
- Implementar un
ControlValueAccessorcompleto, con validación propia integrada. - Construir formularios accesibles: etiquetas asociadas,
aria-invalid,aria-describedby, foco en el primer error y anuncios a lectores de pantalla.
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ó
- El bloque de actualización se ejecuta muchísimas veces. Por eso una llamada a un método dentro de una interpolación es cara: no se evalúa «al pintar», se evalúa continuamente (sección 5.13).
- Angular compara antes de escribir. Las instrucciones guardan el valor anterior y solo tocan el DOM si cambió. El DOM es lo caro; evaluar la expresión es lo barato.
- Tu expresión debe ser analizable estáticamente. No hay
eval: se traduce a código real. De ahí vienen todas las restricciones que siguen.
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 plantilla | Sentencia de plantilla | |
|---|---|---|
| Dónde aparece | {{ }}, [prop]="…" | (evento)="…" |
| Cuándo se evalúa | En cada detección de cambios | Solo cuando ocurre el evento |
Asignaciones (=) | No | Sí |
Encadenar con ; | No | Sí |
Pipes (|) | Sí | No |
| Debe ser libre de efectos | Obligatorio | El efecto es su razón de ser |
En ninguno de los dos casos se permite:
new. Instanciar clases en el bloque de actualización generaría una referencia nueva en cada ciclo: romperíaOnPush, invalidaría toda memoización y provocaríaExpressionChangedAfterItHasBeenCheckedError.- Operadores bit a bit (
|,&,^,~,<<,>>). El motivo del primero es evidente:|ya está ocupado por los pipes. Los demás se excluyen por coherencia y porque no pintan nada en una capa de vista. - Incremento, decremento y asignación compuesta (
++,--,+=) dentro de expresiones. - Acceso al espacio global.
window,document,console,Math,JSONolocalStorageno existen en el contexto de la plantilla. El único contexto es la instancia del componente más las variables locales (#ref,@let, variables de@for).
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.
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.
<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 -->[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.
| Caso | Escribe | Por 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". |
<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" -->
<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
<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 -->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);
}(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.
<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">@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
<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
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
<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>- Alcance. Una
#refes visible en toda la plantilla del componente, salvo que esté dentro de unng-template: entonces solo existe en esa vista incrustada. Una@letes visible desde su declaración hacia abajo, dentro de su bloque. @letes de solo lectura: no puedes reasignarla ni desde la plantilla ni desde la clase. Se recalcula cuando cambian sus dependencias.- Su caso estrella es evitar repetir
| asynctres veces sobre el mismo observable, que crearía tres suscripciones. - Para acceder desde la clase a una
#ref, usa las signal queries del capítulo 3:viewChild('buscador')oviewChild(ModalComponent).
5.3.7 Operadores de plantilla
| Operador | Qué hace | Nota |
|---|---|---|
?. | 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
@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" /> }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
@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ón | Qué usar | Motivo |
|---|---|---|
| Entidades con identificador estable (lo normal) | track item.id | Identidad real: preserva DOM, foco y estado ante reordenaciones, filtros y refrescos. |
Primitivos que pueden repetirse (['a','b','a']) | track $index | track item generaría claves duplicadas y Angular lanzaría error. |
| Lista estática que nunca se reordena ni se filtra | track $index | Es la opción más barata y ninguna de sus desventajas llega a materializarse. |
| Primitivos únicos | track item | El propio valor es la identidad. |
| Clave compuesta | track item.tipo + ':' + item.id | track admite cualquier expresión, incluidas llamadas a métodos. |
<!-- 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>
}
<!-- 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>
}
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
@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.
@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)
| Disparador | Cuándo se activa | Uso típico |
|---|---|---|
on idle | Cuando el navegador está ocioso. Es el valor por defecto si no indicas ninguno. | Contenido secundario que igualmente quieres tener listo. |
on viewport | Cuando el bloque o el elemento referenciado entra en el viewport, vía IntersectionObserver. | Secciones bajo el pliegue: comentarios, gráficos, mapas. |
on interaction | Al hacer clic o pulsar una tecla sobre el placeholder o la referencia indicada. | Acordeones, pestañas, editores que solo se abren bajo demanda. |
on hover | Al pasar el puntero o recibir foco sobre el placeholder o la referencia. | Previsualizaciones, menús desplegables ricos. |
on immediate | En 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 expr | Cuando 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. |
<!-- 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 /> }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 + @case | Comparación estricta y comprobación de tipos de cada caso. |
| (no existía) | @defer | Carga diferida por fragmento de plantilla, no solo por ruta. |
# 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 diffLas 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
<ng-template>declara un fragmento de vista que no se renderiza. Angular lo convierte en unTemplateRef: una plantilla que alguien puede instanciar más tarde, tantas veces como quiera y con el contexto que quiera.<ng-container>agrupa lógicamente sin generar ningún nodo en el DOM. Sirve para aplicar una directiva estructural o agrupar contenido sin ensuciar el marcado con un<div>que rompería un grid o una tabla.
<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.
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 });
}<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
@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);
}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.
@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));
}
}
@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 });
}
| 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
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' },
],
});<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 puro | Pipe impuro | Señal computada | |
|---|---|---|---|
| Cuándo recalcula | Si cambia la referencia de un argumento | En cada ciclo de detección | Si cambia una señal de la que depende |
| Memoización | Sí, del último resultado | No | Sí, y perezosa: solo si alguien la lee |
| Detecta mutaciones de arrays | No (hay que crear un array nuevo) | Sí | No (mismo motivo) |
| Coste típico | Despreciable | Alto en listas grandes | Despreciable |
| Reutilizable entre componentes | Sí | Sí | No: vive en una clase concreta |
@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)
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)
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.
| Criterio | Reactivos | Template-driven |
|---|---|---|
| Dónde vive el modelo | En la clase, explícito y accesible antes de renderizar | En la plantilla; lo generan las directivas |
| Fuente de verdad | El árbol de AbstractControl | La propiedad enlazada con ngModel |
| Sincronía | Síncrono: el control existe desde el constructor | Asíncrono: los controles aparecen tras el primer ciclo |
| Tipado | Fuerte y verificado (desde Angular 14) | Débil: el valor del formulario es any |
| Validación | Funciones puras en TypeScript, testeables aisladamente | Directivas en el marcado |
| Validación dinámica | Natural: setValidators() + updateValueAndValidity() | Difícil y frágil |
| Campos dinámicos | FormArray, addControl, setControl | Solo con @for y nombres calculados; sin control real |
| Testabilidad | Alta: se prueba el modelo sin renderizar la vista | Baja: exige TestBed, fixture.whenStable() y esperas |
| Escalabilidad | Excelente en formularios grandes y anidados | Se degrada rápido a partir de 6-8 campos |
| Curva de aprendizaje | Más pronunciada: hay que aprender el modelo | Muy suave |
| Integración con RxJS y señales | Directa: valueChanges, statusChanges, toSignal | Indirecta y limitada |
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
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.
// 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({});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
| Propiedad | Significado | Clase CSS |
|---|---|---|
pristine / dirty | El valor nunca cambió por interacción del usuario / sí cambió | ng-pristine / ng-dirty |
untouched / touched | El control nunca recibió y perdió el foco / sí | ng-untouched / ng-touched |
valid / invalid | Todos los validadores pasan / alguno falla | ng-valid / ng-invalid |
pending | Hay validadores asíncronos ejecutándose | ng-pending |
disabled / enabled | Excluido del valor y de la validación / activo | (sin clase; se refleja en el atributo disabled) |
status | 'VALID', 'INVALID', 'PENDING' o 'DISABLED' | — |
errors | ValidationErrors | 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).
<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>
<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>
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
| Valor | El valor y la validación se actualizan… | Cuándo usarlo |
|---|---|---|
'change' (por defecto) | En cada pulsación de tecla | Campos con feedback inmediato: medidor de fuerza de contraseña, contador de caracteres. |
'blur' | Al perder el foco | Formularios largos, validadores caros, validación asíncrona. Reduce drásticamente los ciclos. |
'submit' | Solo al enviar el formulario | Asistentes por pasos y formularios sin feedback intermedio. |
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étodo | Comportamiento | Cuá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 / setControl | Añaden, quitan o sustituyen un control del grupo. | Formularios dinámicos. setControl sustituye el control entero: se pierden su estado y sus suscripciones. |
// 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
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
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 [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-*│
└──────────────────────────────────────────────────────┘
"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
| Validador | Comprueba | Clave de error y contenido |
|---|---|---|
Validators.required | Valor no vacío (null, undefined, '' y array vacío fallan) | { required: true } |
Validators.requiredTrue | El valor es exactamente true (checkbox de condiciones) | { required: true } |
Validators.email | Formato 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.nullValidator | Nunca falla; marcador de posición | — |
Validators.compose([…]) / composeAsync([…]) | Combinan varios validadores en uno | Fusión de los errores de todos |
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
/** 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 } };
};
}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
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)),
);
}
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.
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.
/** 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
}@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.
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": [...] }
});
},
}));/** 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;
}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
@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' });
});
}
}<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>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
@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">×</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'
}<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étodo | Dirección | Qué debe hacer | Error habitual |
|---|---|---|---|
writeValue(v) | modelo → vista | Actualizar el estado interno del widget | Llamar a onChange desde dentro: bucle infinito |
registerOnChange(fn) | — | Guardar fn e invocarla en cada edición del usuario | Invocarla también en cambios programáticos |
registerOnTouched(fn) | — | Guardar fn e invocarla en el blur | No invocarla nunca: el control jamás queda touched |
setDisabledState(b) | modelo → vista | Reflejar el estado deshabilitado | No implementarla: form.disable() no hace nada visible |
validate(c) | vista → modelo | Aportar errores propios vía NG_VALIDATORS | Olvidar 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
@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 } }
}
}namees obligatorio en todongModeldentro de un<form>: es la clave con la que se registra el control. Para unngModelajeno al formulario, usa[ngModelOptions]="{ standalone: true }".- Validación por directivas:
required,email,minlength,maxlength,pattern,min,maxcomo atributos. Un validador propio se escribe como directiva registrada enNG_VALIDATORS. #f="ngForm"expone elNgForm, convalue,valid,submittedy el grupo subyacente enf.control.- Los controles se crean de forma asíncrona. En
ngOnInitel formulario aún está vacío; en tests obliga afixture.whenStable(). Es la principal fuente de frustración con esta API. - Cuándo son suficientes: pocos campos, sin validación cruzada ni asíncrona, sin campos dinámicos y sin necesidad de tests de modelo. En cualquier otro caso, reactivos.
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.
<!-- 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>
<!-- 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>
| Requisito | Cómo se implementa | Por qué importa |
|---|---|---|
| Etiqueta asociada | <label for="id"> o envolviendo el input | El 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 vinculado | aria-describedby="id-del-error" | Al enfocar el campo, el lector lee también el motivo del fallo. |
| Anuncio del error | role="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 obligatorio | required nativo o aria-required="true" | Se anuncia como obligatorio antes de que el usuario lo rellene mal. |
| Autocompletado | autocomplete="email", "new-password"… | Reduce mucho el esfuerzo, sobre todo con discapacidad motora. |
| Foco visible | No elimines :focus-visible en el CSS | Sin él, navegar con teclado es imposible. |
| Orden de tabulación | Orden natural del DOM; evita tabindex positivos | Un tabindex="5" desordena toda la página. |
| Resumen de errores al enviar | Contenedor con role="alert" y tabindex="-1", y llevarle el foco | En formularios largos, el usuario sabe de inmediato cuántos errores hay y dónde. |
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.
<!-- 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>
// 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" />
// }
| Medida | Efecto | Cuándo aplicarla |
|---|---|---|
ChangeDetectionStrategy.OnPush | El 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 lee | Siempre, en todos los componentes nuevos. |
| Señales computadas en vez de métodos | Elimina el recálculo por ciclo y estabiliza las referencias | Toda expresión que no sea un simple acceso a propiedad. |
track por identificador | Convierte recreaciones completas en movimientos de nodos | Cualquier lista que se filtre, ordene o refresque. |
updateOn: 'blur' | Reduce por 10-50 el número de ciclos de validación y de emisiones | Formularios con más de 10 campos o con validadores costosos. |
@defer | Saca peso del bundle inicial y retrasa el trabajo de renderizado | Gráficos, editores enriquecidos, mapas, contenido bajo el pliegue. |
| Virtual scroll del CDK | Renderiza solo las filas visibles: el DOM deja de crecer con los datos | Listas de más de ~200 filas. |
| Modo zoneless | Elimina zone.js: la detección la disparan solo señales y eventos | Proyectos ya migrados a señales; comprueba el estado de la API en tu versión. |
| Evitar pipes impuros | Suprime N ejecuciones por ciclo | Siempre que exista una alternativa con señal computada. |
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é.
<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.
| Contexto | Dónde aparece | Qué 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 iframe | No se puede sanear: exige bypassSecurityTrustResourceUrl explícito. |
// 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>
// 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');
- No construyas HTML con datos del usuario. Es la regla que resume todo lo anterior. Si necesitas formato, usa la plantilla, componentes y CSS; el HTML dinámico es la puerta del XSS.
bypassSecurityTrustHtmles un contrato: le juras a Angular que ese HTML es seguro. Solo puedes jurarlo sobre cadenas literales que están en tu código fuente.- La sanitización de Angular es una red, no un plan. Sanea también en el servidor: es donde el dato se persiste y desde donde se sirve a otros clientes.
- Plantillas dinámicas. No hay forma soportada de compilar una plantilla desde una cadena en tiempo de ejecución con AOT, y es una bendición: un intérprete de plantillas alimentado por datos sería un
evalencubierto. Para interfaces configurables usaNgComponentOutletcon un registro cerrado de componentes permitidos. - CSP y Trusted Types. Angular es compatible con una Content Security Policy estricta gracias a AOT. Añade la cabecera con
script-src 'self'y, si tu navegador objetivo lo permite,require-trusted-types-for 'script'.
5.15 Errores comunes y cómo solucionarlos
| Error | Causa real | Solució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 renderizar | Comprobar 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 intermedio | Añadir formGroupName="a" en el elemento que envuelve a b |
ngModel cannot be used to register form controls with a parent formGroup directive | Se mezclan las dos APIs en el mismo elemento o formulario | Elegir una; si el ngModel es ajeno al modelo, [ngModelOptions]="{ standalone: true }" |
ExpressionChangedAfterItHasBeenCheckedError | Una 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 ngAfterViewInit | Sustituir el método por una señal computada; mover el cambio a ngOnInit; en último recurso afterNextRender() |
NG0955: track expression resulted in duplicated keys | Dos elementos producen la misma clave de track | Investigar el duplicado; si son primitivos que legítimamente se repiten, track $index |
| La lista parpadea o pierde el foco al refrescar | track por índice o por referencia sobre datos que llegan nuevos en cada respuesta HTTP | track item.id |
| El formulario nunca se puede enviar | Un validador asíncrono que emite pero no completa: el control se queda en PENDING | Añadir first() o take(1), y catchError para el fallo de red |
form.value no incluye un campo | Ese control está deshabilitado | getRawValue(), o marcar el campo como readonly en lugar de deshabilitarlo |
Un campo tipado devuelve null tras reset() | El control no es nonNullable | NonNullableFormBuilder, { nonNullable: true } o reset(valorInicial) |
| Los validadores nuevos no hacen efecto | Se llamó a setValidators() sin updateValueAndValidity() | Añadirlo siempre justo después, con { emitEvent: false } |
| El navegador se congela al editar un campo | Bucle: 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 control | Dos 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 setDisabledState | Implementarlo y reflejar el estado en el widget |
Un control propio nunca llega a touched | El CVA no invoca el callback de registerOnTouched | Llamarlo en el blur del elemento interno |
NG01203: No value accessor for form control | formControlName sobre un elemento que no es un control nativo ni implementa CVA | Implementar ControlValueAccessor o usar un elemento de formulario nativo |
El contenido de un @defer sigue en el bundle inicial | El componente se usa también fuera del bloque, no es standalone, o se referencia desde la clase | Usarlo solo dentro del @defer, hacerlo standalone y quitar las referencias en TypeScript |
| Tres peticiones HTTP idénticas al pintar la vista | Tres | 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 escribir | Normal: el ciclo de validación reconstruye errors | Guardar 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.
trackpor 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
NonNullableFormBuildery tipos derivados conReturnType<typeof form.getRawValue>. getRawValue()al enviar siempre que exista algún control deshabilitado.- Errores solo cuando
touchedo tras enviar, ymarkAllAsTouched()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-describedbyy foco al primer error en cada formulario, sin excepción.@deferconprefetchpara el contenido pesado que el usuario acabará abriendo.OnPushen 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,
OnPushroto. track $indexpor inercia en listas que se reordenan o se filtran.- Pipes impuros para filtrar u ordenar listas.
- Mezclar
ngModelyformControlNameen el mismo formulario. - Deshabilitar el botón de envío con
form.invalid: el usuario no sabe qué falta. - Cascadas de
@ifpor cada clave de error repetidas campo a campo. - Validadores asíncronos sin
debounce, sincatchErrory sinfirst(). setErrors()sin fusionar los errores existentes.bypassSecurityTrustHtmlsobre 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?
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?
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?
@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?
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?
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?
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?
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?
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?
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?
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?
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
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.
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.
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
/** 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
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. Necesitasattr.para ARIA,colspan, SVG ydata-*, y para eliminar un atributo connull.trackes la identidad de cada elemento de una lista. Con un identificador estable Angular mueve nodos y conserva foco, estado y animaciones; con$indexreescribe posiciones y el contenido puede acabar en la fila equivocada.@deferlleva 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+ngTemplateOutletconvierte un componente rígido en una pieza reutilizable de verdad;NgComponentOutletcubre 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
TestBedy preparado para validación cruzada, asíncrona y campos dinámicos. Los template-driven se reservan para lo trivial. valueexcluye 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. Sinfirst(), el control se queda enPENDINGpara siempre. ControlValueAccessores el adaptador entre tu widget y el modelo de formularios; olvidarregisterOnTouchedosetDisabledStateproduce fallos sutiles.- Accesibilidad y seguridad no son extras.
labelasociada,aria-invalid,aria-describedby,fieldset/legendy foco al primer error; y jamás HTML construido con datos del usuario.
5.20 Recursos adicionales
- Angular · Guía de plantillas — referencia oficial de toda la sintaxis de binding, con la lista exacta de operadores admitidos en cada versión.
- Angular · Control flow con bloques —
@if,@for,@switchy las reglas detrack. - Angular ·
@defer— disparadores, prefetch, dependencias diferibles e interacción con SSR e hidratación incremental. - Angular · Formularios reactivos — modelo de controles,
FormBuildery formularios dinámicos. - Angular · Formularios tipados — inferencia de tipos,
nonNullable,FormRecordy migración. - Angular · Validación de formularios — validadores integrados, personalizados, cruzados y asíncronos.
- Angular · API de
ControlValueAccessor— contrato exacto de los cuatro métodos. - Angular · Seguridad — contextos de sanitización,
bypassSecurityTrust*, CSP y Trusted Types. - Angular CDK · Virtual scrolling —
cdk-virtual-scroll-viewportpara listas largas. - W3C WAI · Tutorial de formularios accesibles — la referencia canónica sobre etiquetas, agrupación, instrucciones y notificación de errores.
- MDN ·
aria-describedby— cómo vincular mensajes de ayuda y de error a un campo.
HttpClient, interceptores y el manejo de errores de red de extremo a extremo.