Módulo 16: Formularios por plantilla

Objetivos del módulo

  • Situar los formularios por plantilla frente a los reactivos y a Signal Forms, y saber cuándo conviene cada uno
  • Importar FormsModule y entender qué activa en tus plantillas
  • Enlazar campos con ngModel, [ngModel], [(ngModel)] y señales, y saber por qué cada campo necesita name
  • Explicar qué objetos crean NgForm y NgModel por dentro, y por qué el formulario tarda una microtarea en existir
  • Usar ngForm para leer el valor y el estado del formulario, enviarlo con (ngSubmit) y reiniciarlo
  • Conectar cada tipo de control: texto, número, fecha, casilla, radio, select y select múltiple
  • Agrupar campos con ngModelGroup y repartir un formulario en varios componentes
  • Validar con los atributos required, minlength, maxlength, pattern, email, min y max
  • Leer los estados de un control y darles estilo con las clases ng-*
  • Mostrar errores en el momento adecuado y de forma accesible
  • Cambiar cuándo se actualiza un campo con ngModelOptions y ngFormOptions
  • Escribir validadores propios como directivas: síncronos, entre campos y asíncronos contra la API
  • Pintar en el formulario los errores 422 del servidor
  • Diagnosticar los errores más habituales, sobre todo los que aparecen con OnPush por defecto

1. Las tres formas de hacer un formulario en Angular

Un formulario parece poca cosa: unos cuantos <input> y un botón. Lo difícil llega después. Hay que saber qué ha escrito el usuario, si es correcto, si ya ha tocado el campo, qué error mostrar y en qué momento, si se puede enviar, cómo reiniciarlo y qué hacer cuando el servidor lo rechaza. Con JavaScript puro, cada una de esas preguntas acaba en un querySelector y un montón de variables sueltas.

Angular resuelve todo eso con un modelo del formulario: un árbol de objetos que guarda, para cada campo, su valor, sus errores y su estado. Lo que cambia entre una forma de trabajar y otra es quién crea ese árbol y dónde se escribe la lógica.

  Por plantilla (este módulo) Reactivos (módulo 17) Signal Forms (módulo 18)
Quién crea el modelo Las directivas de la plantilla Tu código TypeScript Tu código, a partir de una señal
Dónde se valida Atributos en el HTML Funciones en TypeScript Un esquema en TypeScript
Módulo que importas FormsModule ReactiveFormsModule @angular/forms/signals
Fuente de la verdad Tus variables y el control El FormControl Tu señal
Formularios dinámicos Incómodos Muy bien Muy bien
Código necesario Poco Algo más Algo más

En los formularios por plantilla (template-driven) escribes HTML casi normal, añades unos atributos, y Angular deduce el modelo a partir de lo que encuentra. Es la forma más rápida de tener un formulario sencillo funcionando, y la que encontrarás en muchísimo código existente.

📘 Concepto: En un formulario por plantilla no creas tú los objetos del formulario. Las directivas NgForm y NgModel los crean mientras se renderiza la plantilla. Tú solo describes los campos y sus reglas en el HTML.


2. Tu primer formulario

Empezamos por un alta de departamento para la aplicación EMPRESA. Hay tres pasos: importar FormsModule, poner name y [(ngModel)] en cada campo, y escuchar (ngSubmit).

// src/app/alta-departamento/alta-departamento.ts
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-alta-departamento',
  imports: [FormsModule],
  template: `
    <h2>Nuevo departamento</h2>
    <form (ngSubmit)="guardar()">
      <label>Nombre <input name="nombre" [(ngModel)]="nombre" /></label>
      <label>Localidad <input name="localidad" [(ngModel)]="localidad" /></label>
      <label>Presupuesto <input type="number" name="presupuesto" [(ngModel)]="presupuesto" /></label>
      <button type="submit">Guardar</button>
    </form>
    <p class="vista">Vista previa: {{ nombre() }} ({{ localidad() }}), {{ presupuesto() }} euros</p>
    @if (guardado(); as d) {
      <p class="ok">Guardado: {{ d }}</p>
    }
  `,
})
export class AltaDepartamento {
  protected readonly nombre = signal('');
  protected readonly localidad = signal('');
  protected readonly presupuesto = signal(0);
  protected readonly guardado = signal('');

  protected guardar() {
    const datos = { nombre: this.nombre(), localidad: this.localidad(), presupuesto: this.presupuesto() };
    this.guardado.set(JSON.stringify(datos));
  }
}

Formulario de alta de departamento con los campos rellenos, una vista previa que los repite y el mensaje de guardado

Figura 1. La vista previa cambia con cada tecla, y (ngSubmit) se dispara tanto con el botón como con Intro.

Fíjate en lo que has conseguido con muy poco código:

  • [(ngModel)]="nombre" une el campo y la señal en los dos sentidos. Si escribes, cambia la señal. Si cambias la señal, cambia el campo. Es la misma sintaxis de doble enlace que model() en el módulo 6.
  • Al enlazar una señal no se escriben los paréntesis: es [(ngModel)]="nombre", no [(ngModel)]="nombre()". Hay que pasar la señal para que Angular pueda escribir en ella.
  • El presupuesto llega como número, no como texto, porque type="number" activa un conversor específico.
  • (ngSubmit) sustituye a (submit). Además de avisarte, cancela el envío nativo, así que la página no se recarga.

Si rellenas los campos y pulsas Intro en el último, esto es lo que aparece en pantalla:

Vista previa: I+D (Bilbao), 90000 euros
Guardado: {"nombre":"I+D","localidad":"Bilbao","presupuesto":90000}

Por qué cada campo necesita name

Dentro de un <form>, name no es un adorno: es la clave con la que el campo se registra en el modelo. Si la olvidas, Angular se niega a seguir:

ERROR RuntimeError: NG01352: If ngModel is used within a form tag, either the name attribute
must be set or the form control must be defined as 'standalone' in ngModelOptions.

⚠️ Importante: Si no importas FormsModule, el compilador te avisa: NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'. Y #f="ngForm" da NG8003: No directive found with exportAs 'ngForm'. Cada componente debe importar lo que usa, como viste en el módulo 3.

Las tres formas de escribir ngModel

Sintaxis Qué hace Cuándo usarla
ngModel Registra el campo, sin valor inicial ni variable Solo te interesa el valor al enviar
ngModel="VENTAS" o [ngModel]="valor" Valor inicial, en un solo sentido Datos de partida que no necesitas seguir
[(ngModel)]="senal" Doble enlace Quieres reaccionar a cada cambio

Las tres registran el campo en el formulario. Con ngModel a secas no hace falta declarar nada en la clase: leerás todos los valores de golpe desde f.value, como verás en la sección 4.

Enlazar un <input> suelto

Fuera de un <form>, ngModel funciona sin name, porque no hay ningún formulario en el que registrarse. Es muy útil para controles aislados, como un filtro:

<input [(ngModel)]="busqueda" placeholder="Buscar empleado" />

3. Qué ocurre por dentro

Para no perderse con los errores de esta forma de trabajar, conviene saber qué construye Angular. FormsModule trae un conjunto de directivas cuyos selectores se aplican solos en cuanto ven lo que buscan:

  • NgForm tiene el selector form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]. Por eso todo <form> se convierte en un NgForm en cuanto importas FormsModule.
  • NgModel se activa con [ngModel].
  • Los validadores se activan con su atributo: [required], [email], [minlength]…
  • Los conversores de valor (value accessors) se activan según el elemento: input[type=number], input[type=checkbox], select…

Tres columnas: la plantilla con form, ngModelGroup e input; las directivas que activan cada uno; y los objetos FormGroup y FormControl que crean, formando un árbol

Figura 2. Cada elemento de la plantilla activa directivas, y estas crean el árbol de FormGroup y FormControl.

Cuatro piezas hacen el trabajo:

Pieza Responsabilidad
FormControl Guarda el valor, los errores y el estado de un campo
FormGroup Reúne controles y calcula el estado del conjunto
NgModel / NgForm Crean esos objetos y los unen a la plantilla
ControlValueAccessor Traduce entre el control y el elemento del DOM

El value accessor es el traductor: sabe que un <input type="checkbox"> guarda su valor en checked y no en value, y que un <input type="number"> debe entregar un número. Aprenderás a escribir el tuyo en el módulo 17.

El dato viaja de la señal al FormControl y de ahí al input, y vuelve del input al FormControl y a la señal, con una nota sobre las microtareas

Figura 3. La ida (de tu código al campo) espera a una microtarea. La vuelta (del usuario a tu código) ocurre en el mismo evento.

El formulario tarda una microtarea en existir

Este es el detalle que más confunde. NgForm y NgModel no registran los controles mientras se dibuja la plantilla, sino justo después, dentro de un Promise.resolve().then(...). Lo hacen a propósito: si registraran o escribieran valores en pleno renderizado, cambiarían expresiones que Angular ya ha comprobado. Una microtarea es trabajo que el navegador ejecuta en cuanto termina el código en curso, antes de pintar (repásalo en el Curso de JavaScript).

Compruébalo con un componente que mira el formulario en dos momentos:

import { JsonPipe } from '@angular/common';
import { AfterViewInit, Component, viewChild } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-por-dentro',
  imports: [FormsModule, JsonPipe],
  template: `
    <form #f="ngForm">
      <input name="nombre" ngModel="VENTAS" required />
      <input name="localidad" ngModel />
      <button [disabled]="f.invalid">Guardar</button>
    </form>
    <pre>{{ f.value | json }} valido={{ f.valid }}</pre>
  `,
})
export class PorDentro implements AfterViewInit {
  private readonly formulario = viewChild.required(NgForm);

  ngAfterViewInit() {
    const form = this.formulario();
    console.log('En ngAfterViewInit:', JSON.stringify(form.value));
    setTimeout(() => {
      console.log('Un instante después:', JSON.stringify(form.value));
    });
  }
}
En ngAfterViewInit: {}
Un instante después: {"nombre":"VENTAS","localidad":""}

En ngAfterViewInit, que normalmente es el primer momento seguro para leer la vista, el formulario sigue vacío. La consecuencia práctica: no puedes llamar a f.setValue(...) ni leer f.controls['nombre'] en ese gancho. En su lugar, da los valores iniciales con [ngModel], o espera a que el usuario actúe.

La plantilla no sufre el problema porque, al terminar la microtarea, NgModel llama a markForCheck() y la vista se repinta. Por eso un botón con [disabled]="f.invalid" queda bien aunque el campo requerido esté vacío desde el principio: comprobado en el navegador, el botón aparece deshabilitado y se habilita al escribir.


4. NgForm: el formulario completo

La variable de plantilla #f="ngForm" te da la directiva NgForm. La parte ="ngForm" importa: pide la directiva, no el elemento. Sin ella, f sería el HTMLFormElement del DOM. Lo mismo vale para #c="ngModel", y el compilador te delata si lo olvidas:

TS2551: Property 'invalid' does not exist on type 'HTMLInputElement'. Did you mean 'oninvalid'?
Miembro de NgForm Qué da
f.value Objeto con el valor de todos los campos habilitados
f.valid, f.invalid, f.pending Estado del conjunto
f.dirty, f.pristine, f.touched, f.untouched Si se ha editado o visitado algún campo
f.status 'VALID', 'INVALID', 'PENDING' o 'DISABLED'
f.submitted Si se ha enviado desde el último reinicio
f.controls Diccionario de controles por nombre
f.form El FormGroup raíz
f.resetForm(valor?) Reinicia valores, estados y submitted
f.reset() Reinicia valores y estados, pero no submitted
f.setValue(obj) Rellena todos los campos a la vez

Dos formas de recoger los datos

La primera ya la has visto: una señal por campo. La segunda aprovecha que el formulario ya guarda todo. Con ngModel a secas, escribes menos código en la clase, y en el envío te llega el objeto completo:

<form #f="ngForm" (ngSubmit)="guardar(f)">
  <input name="nombre" ngModel required />
  <input type="number" name="presupuesto" ngModel />
  <button type="submit">Guardar</button>
</form>
protected guardar(f: NgForm) {
  if (f.invalid) {
    return;
  }
  console.log(f.value); // { nombre: 'I+D', presupuesto: 90000 }
}

El inconveniente es que f.value es de tipo any. Es uno de los motivos por los que los formularios grandes se llevan mejor con Signal Forms o con reactivos tipados.

También puedes localizar el formulario desde la clase con viewChild:

private readonly formulario = viewChild.required(NgForm);

Validación nativa: novalidate se pone solo

Con FormsModule, Angular añade el atributo novalidate a cada <form>. Así el navegador no muestra sus globos de error y puedes pintar los tuyos con el mismo estilo en toda la aplicación. Se ve en el DOM: el formulario de la figura 1 lleva novalidate sin que lo hayas escrito. Si prefieres la validación del navegador, añade ngNativeValidate al formulario.

Y si quieres un <form> sin nada de esto, por ejemplo uno que se envíe de verdad a otra URL, ponle ngNoForm.

Reiniciar: reset() frente a resetForm()

Aquí hay dos trampas juntas. Este es el resultado real de un formulario con [(ngModel)]="texto", required y la señal signal('hola'), pulsando cada acción tras un envío:

Al empezar:                 texto=hola    submitted=false  pristine=true  VALID
Tras enviar:                texto=hola    submitted=true   pristine=true  VALID
f.reset():                  texto=null    submitted=true   pristine=true  INVALID
f.resetForm():              texto=null    submitted=false  pristine=true  INVALID
<button type="reset">:      texto=null    submitted=false  pristine=true  INVALID
f.resetForm({ t: 'inicial' }): texto=inicial submitted=false pristine=true VALID

Saca tres conclusiones:

  1. Usa resetForm(), no reset(). Con reset() el formulario sigue marcado como enviado y, si muestras errores tras el envío, aparecerán todos de golpe sobre un formulario vacío. Un botón nativo type="reset" llama a resetForm().
  2. Reiniciar escribe null en tus señales, aunque las declares como signal(''). TypeScript no puede avisarte, porque ese null llega en tiempo de ejecución. Un computed que haga nombre().toUpperCase() fallará con TypeError: Cannot read properties of null.
  3. Para volver a unos valores concretos, pásalos: f.resetForm({ nombre: '', presupuesto: 0 }). Las claves son los name de los campos.

💡 Consejo: Si usas [(ngModel)], reinicia siempre con valores explícitos. Así el tipo de tus señales sigue diciendo la verdad.

Botones dentro de un formulario

Un <button> sin type dentro de un <form> es de tipo submit. Un botón “Añadir teléfono” sin type="button" enviaría el formulario entero. Acostúmbrate a escribir siempre el tipo.


5. Todos los tipos de control

Cada elemento tiene su forma de leer y escribir. Este componente los reúne, y a la derecha muestra f.value, así que verás el tipo que produce cada uno.

import { JsonPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Opcion {
  deptId: number;
  nombre: string;
}

@Component({
  selector: 'app-ficha-controles',
  imports: [FormsModule, JsonPipe],
  template: `
    <h2>Ficha de empleado</h2>
    <form #f="ngForm" class="dos-columnas">
      <div class="campos">
        <label>Nombre <input name="nombre" [(ngModel)]="nombre" /></label>
        <label>Notas <textarea name="notas" [(ngModel)]="notas" rows="2"></textarea></label>
        <label>Salario <input type="number" name="salario" [(ngModel)]="salario" /></label>
        <label>Fecha de alta <input type="date" name="fechaAlta" [(ngModel)]="fechaAlta" /></label>
        <label>Dedicación: {{ dedicacion() }} %
          <input type="range" name="dedicacion" min="0" max="100" step="10" [(ngModel)]="dedicacion" />
        </label>
        <label class="fila"><input type="checkbox" name="activo" [(ngModel)]="activo" /> Activo</label>
        <fieldset>
          <legend>Jornada</legend>
          @for (j of jornadas; track j) {
            <label class="fila">
              <input type="radio" name="jornada" [value]="j" [(ngModel)]="jornada" /> {{ j }}
            </label>
          }
        </fieldset>
        <label>Departamento
          <select name="departamento" [(ngModel)]="departamento" [compareWith]="mismoDepartamento">
            <option [ngValue]="null">Sin asignar</option>
            @for (d of departamentos; track d.deptId) {
              <option [ngValue]="d">{{ d.nombre }}</option>
            }
          </select>
        </label>
        <label>Idiomas
          <select name="idiomas" multiple [(ngModel)]="idiomas">
            @for (i of ['Inglés', 'Francés', 'Alemán']; track i) {
              <option [value]="i">{{ i }}</option>
            }
          </select>
        </label>
      </div>
      <pre>{{ f.value | json }}</pre>
    </form>
  `,
})
export class FichaControles {
  protected readonly departamentos: Opcion[] = [
    { deptId: 20, nombre: 'VENTAS' },
    { deptId: 30, nombre: 'INFORMÁTICA' },
  ];
  protected readonly jornadas = ['Completa', 'Parcial', 'Remota'];

  protected readonly nombre = signal('Marta');
  protected readonly notas = signal('');
  protected readonly salario = signal<number | null>(2400);
  protected readonly fechaAlta = signal('2024-03-01');
  protected readonly dedicacion = signal(100);
  protected readonly activo = signal(true);
  protected readonly jornada = signal('Completa');
  // Otro objeto con el mismo id: solo coincide gracias a compareWith
  protected readonly departamento = signal<Opcion | null>({ deptId: 30, nombre: 'INFORMÁTICA' });
  protected readonly idiomas = signal<string[]>(['Inglés']);

  protected mismoDepartamento(a: Opcion | null, b: Opcion | null) {
    return a?.deptId === b?.deptId;
  }
}

Ficha de empleado con todos los tipos de control a la izquierda y, a la derecha, el objeto JSON que producen

Figura 4. El valor del formulario conserva los tipos: números, booleanos, objetos y listas.

Control Valor que produce Detalle
Texto, textarea string El vacío es ''
type="number", type="range" number Un campo numérico vacío da null, no 0 ni ''
type="date" string 'AAAA-MM-DD' No es un Date, y coincide con lo que espera la API
type="checkbox" boolean Una casilla, un booleano
type="radio" El [value] elegido Todos los del grupo llevan el mismo name
select El [ngValue] elegido Puede ser un número o un objeto
select multiple Lista de valores Aunque solo se elija uno

[value] frente a [ngValue]

[value] guarda un texto. Si escribes <option [value]="20">, tu campo acaba con la cadena '20', porque un atributo HTML siempre es texto. [ngValue] guarda el valor tal cual: un número, un objeto o null. En un select, usa [ngValue] salvo que quieras texto de verdad.

compareWith: objetos que son iguales sin ser el mismo

Angular decide qué opción marcar comparando con Object.is. Dos objetos con el mismo contenido son objetos distintos, así que un departamento recibido de la API nunca coincidirá con uno de la lista de opciones, y el select aparecería vacío. [compareWith] le dice qué significa “ser el mismo”. En la figura 4 aparece seleccionado INFORMÁTICA gracias a esa función.

💡 Consejo: Si el select guarda solo el identificador ([ngValue]="d.deptId"), no necesitas compareWith, y el objeto que envías a la API queda más limpio.


6. Modelo: señales, objetos y un error silencioso

Hay varias maneras de guardar los datos del formulario, y una de ellas parece correcta pero no lo es.

interface Ficha {
  nombre: string;
  salario: number;
}

Una señal por campo. Es lo más claro y lo que usamos en este módulo:

<input name="nombre" [(ngModel)]="nombre" />

Un objeto corriente. Funciona con OnPush porque el evento del campo marca la vista:

protected suelta = { nombre: 'Luis' };
<input name="nombre" [(ngModel)]="suelta.nombre" />

Una señal con un objeto. Necesita separar la entrada y la salida para crear un objeto nuevo:

<input name="nombre" [ngModel]="ficha().nombre" (ngModelChange)="cambiar('nombre', $event)" />
protected readonly ficha = signal<Ficha>({ nombre: 'Ana', salario: 1 });

protected cambiar<K extends keyof Ficha>(campo: K, valor: Ficha[K]) {
  this.ficha.update((f) => ({ ...f, [campo]: valor }));
}

El error: [(ngModel)]="ficha().nombre"

<!-- Compila, pero mete la mano dentro de la señal -->
<input name="nombre" [(ngModel)]="ficha().nombre" />

Esto compila y hasta parece funcionar, porque [(ngModel)] equivale a ficha().nombre = $event. Ese es el problema: modifica el objeto que hay dentro de la señal sin pasar por set. La señal no se entera, así que un computed que dependa de ella conserva su valor antiguo. Es la mutación de la que te avisó el módulo 5. Esta es la prueba, con mayus = computed(() => this.ficha().nombre.toUpperCase()) en pantalla, tras escribir Luis sobre Ana:

nombre=Luis mayus=ANA

El campo y la lectura directa muestran Luis, porque el evento repinta la vista, pero el computed se queda en ANA.

En cambio, con una llamada a señal directa el compilador sí protesta:

NG5002: Unsupported expression in a two-way binding

Te sale al escribir [(ngModel)]="nombre()". El remedio es quitar los paréntesis.


7. Agrupar campos con ngModelGroup

ngModelGroup crea un FormGroup dentro del formulario. Sirve para dar forma anidada al valor y para validar un conjunto de campos, como verás en la sección 12.

<form #f="ngForm">
  <label>Nombre <input name="nombre" ngModel required /></label>
  <div ngModelGroup="direccion">
    <label>Calle <input name="calle" ngModel required /></label>
    <label>Ciudad <input name="ciudad" ngModel required /></label>
  </div>
</form>
<pre>{{ f.value | json }} {{ f.status }}</pre>
{ "nombre": "", "direccion": { "calle": "", "ciudad": "" } } INVALID

El grupo tiene su propio estado. Con #dir="ngModelGroup" puedes preguntar dir.invalid o dir.touched, y el elemento recibe las mismas clases ng-* que un campo, lo que permite marcar un bloque entero.

Repartir el formulario en varios componentes

Si la dirección se repite en clientes y en pedidos, querrás sacarla a un componente. Tal cual, no funciona: NgModel busca su formulario con @Host(), que detiene la búsqueda en el límite del componente. La solución es prestarle el formulario del padre con viewProviders:

import { JsonPipe } from '@angular/common';
import { Component } from '@angular/core';
import { ControlContainer, FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-form-direccion',
  imports: [FormsModule],
  viewProviders: [{ provide: ControlContainer, useExisting: NgForm }],
  template: `
    <div ngModelGroup="direccion">
      <label>Calle <input name="calle" ngModel required /></label>
      <label>Ciudad <input name="ciudad" ngModel required /></label>
    </div>
  `,
})
export class FormDireccion {}

@Component({
  selector: 'app-form-cliente',
  imports: [FormsModule, FormDireccion, JsonPipe],
  template: `
    <form #f="ngForm">
      <label>Nombre <input name="nombre" ngModel required /></label>
      <app-form-direccion />
    </form>
    <pre>{{ f.value | json }} {{ f.status }}</pre>
  `,
})
export class FormCliente {}

La salida es exactamente la del ejemplo anterior: para el formulario, los campos del hijo son suyos. El proveedor va en viewProviders y no en providers porque solo debe verlo la vista de este componente, que es donde @Host() termina de buscar. La jerarquía de inyectores está en el módulo 8.

El aviso NG01354 🆕 v22.1

🆕 Desde Angular 22.1. No existe en la LTS (Angular 21): el campo del hijo se queda fuera del formulario sin ningún aviso. Si usas la LTS: comprueba con {{ f.value | json }} que aparecen todos los campos; el arreglo con viewProviders es el mismo.

Si olvidas viewProviders, el campo del hijo no se registra en el formulario del padre. Sin required ni ningún error visible, el valor enviado simplemente no lo lleva. Quitando el proveedor de un hijo con un campo calle obligatorio, la salida es { "nombre": "" } VALID: el formulario se declara válido ignorando la calle. Desde 22.1.3, en modo desarrollo, la consola te avisa:

NG01354: ngModel on a form control inside a child component cannot register with the NgForm
in the parent component because @Host() stops injection at the component boundary.

8. Validación con atributos

Validar en un formulario por plantilla consiste en añadir atributos. Cada uno activa una directiva, y si el valor no cumple la regla, deja una entrada en el objeto errors del control.

Atributo Regla Clave en errors Detalle del error
required No vacío required true
required en casilla Debe estar marcada required true
minlength="2" Longitud mínima minlength { requiredLength, actualLength }
maxlength="30" Longitud máxima maxlength { requiredLength, actualLength }
pattern="[0-9]{9}" Expresión regular pattern { requiredPattern, actualValue }
email Formato de email email true
min="1000" Número mínimo min { min, actual }
max="10000" Número máximo max { max, actual }

Todos admiten enlace de propiedad si el valor es dinámico: [minlength]="minimo()", [required]="esObligatorio()".

Cuatro detalles que evitan sorpresas:

  • Un campo vacío no incumple minlength, pattern, email, min ni max. Esas reglas solo miran si hay algo escrito, para que un campo opcional pueda quedarse vacío. El teléfono del ejemplo siguiente es opcional por eso. Si además es obligatorio, añade required.
  • required da por bueno un texto de espacios: ' ' tiene longitud 3. El ejercicio 3 lo arregla.
  • email admite direcciones sin dominio: ana@empresa pasa la validación. El estándar lo permite. Si necesitas un punto en el dominio, añade un pattern, y deja la comprobación definitiva al servidor.
  • min y max solo existen en input[type=number]. Su selector es input[type=number][min][ngModel]. En un <input type="date">, el atributo min limita el calendario del navegador, pero Angular no lo valida.

pattern ancla la expresión por ti cuando es texto: [0-9]{9} se convierte en ^[0-9]{9}$, así que no acepta diez cifras.

⚠️ Importante: La validación del navegador protege la experiencia del usuario, no tus datos. Cualquiera puede saltársela con las herramientas de desarrollo o llamando a la API directamente. El servidor debe validar siempre, y la sección 13 muestra cómo pintar sus errores.


9. Estados y clases CSS

Cada control responde a tres preguntas independientes, y cada respuesta tiene una propiedad y una clase CSS.

Tres filas de estados: pristine pasa a dirty cuando el usuario edita, untouched pasa a touched al salir del campo, y valid, pending e invalid indican si el valor es correcto

Figura 5. Los tres ejes son independientes: un campo tiene siempre una clase de cada fila.

Propiedad Clase Significa
pristine / dirty ng-pristine / ng-dirty El usuario no ha cambiado o sí ha cambiado el valor
untouched / touched ng-untouched / ng-touched El usuario no ha salido o sí ha salido del campo
valid / invalid ng-valid / ng-invalid Cumple o no cumple las reglas
pending ng-pending Hay un validador asíncrono en marcha
submitted (solo en NgForm) ng-submitted (solo en el <form>) Se ha enviado

Para verlo de primera mano, este campo muestra sus propiedades en vivo:

<form #f="ngForm">
  <label>Oficio <input name="oficio" ngModel required minlength="3" #c="ngModel" /></label>
</form>
<table>
  <tr><th>valid</th><td>{{ c.valid }}</td><th>invalid</th><td>{{ c.invalid }}</td></tr>
  <tr><th>pristine</th><td>{{ c.pristine }}</td><th>dirty</th><td>{{ c.dirty }}</td></tr>
  <tr><th>untouched</th><td>{{ c.untouched }}</td><th>touched</th><td>{{ c.touched }}</td></tr>
  <tr><th>status</th><td>{{ c.status }}</td><th>errors</th><td>{{ c.errors | json }}</td></tr>
</table>

(Añade JsonPipe a los imports del componente).

Tabla de estados de un campo Oficio recién cargado: inválido, sin tocar y sin cambios, con el error required

Figura 6. Recién cargado: es inválido, pero el usuario aún no ha hecho nada.

La misma tabla tras escribir ab y salir: sigue inválido, ahora dirty y touched, con el error minlength y sus detalles

Figura 7. Tras escribir “ab” y salir del campo: cambian los tres ejes y el detalle del error.

Estas son las clases reales que tiene el <input> en cada momento:

Al cargar:         ng-untouched ng-pristine ng-invalid
Al escribir "ab":  ng-untouched ng-invalid ng-dirty
Al salir:          ng-invalid ng-dirty ng-touched

Un detalle importante: dirty y touched solo reflejan lo que hace el usuario. Si cambias el valor desde el código, el campo sigue pristine. Gracias a eso, un formulario de edición recién cargado desde la API no aparece como modificado, como comprobarás en la sección 13.

Con estas clases puedes dar estilo sin tocar la plantilla:

/* Rojo si es inválido y el usuario ya pasó por él, o si intentó enviar */
input.ng-invalid.ng-touched,
form.ng-submitted input.ng-invalid {
  border-color: #c62828;
  background: #ffebee;
}

/* Verde solo si lo ha cambiado el usuario y está bien */
input.ng-valid.ng-dirty {
  border-color: #2e7d32;
}

⚠️ Importante: No pongas la regla sobre .ng-invalid a secas. Un formulario vacío aparecería en rojo nada más abrirlo, regañando al usuario antes de que haga nada. Por el mismo motivo <form> y ngModelGroup también reciben estas clases: si escribes .ng-invalid { border: ... }, el formulario entero tendrá borde.


10. Mostrar los errores en el momento adecuado

La regla habitual es esta: enseña el error de un campo si es inválido y además el usuario ya ha pasado por él o ha intentado enviar. El siguiente formulario completo reúne todo lo anterior.

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

@Component({
  selector: 'app-alta-empleado',
  imports: [FormsModule],
  styles: `
    input.ng-invalid.ng-touched,
    select.ng-invalid.ng-touched,
    form.ng-submitted input.ng-invalid,
    form.ng-submitted select.ng-invalid {
      border-color: #c62828;
      background: #ffebee;
    }
    input.ng-valid.ng-dirty {
      border-color: #2e7d32;
    }
    .error {
      color: #c62828;
      font-size: 0.85rem;
      margin: 0.15rem 0 0;
    }
  `,
  template: `
    <h2>Alta de empleado</h2>
    <form #f="ngForm" (ngSubmit)="guardar(f)">
      <div class="campo">
        <label for="nombre">Nombre</label>
        <input id="nombre" name="nombre" [(ngModel)]="nombre" #nombreCtrl="ngModel"
          required minlength="2" maxlength="30"
          [attr.aria-invalid]="mostrar(nombreCtrl.invalid, nombreCtrl.touched, f.submitted)"
          aria-describedby="nombre-error" />
        @if (mostrar(nombreCtrl.invalid, nombreCtrl.touched, f.submitted)) {
          <p class="error" id="nombre-error">
            @if (nombreCtrl.hasError('required')) {
              El nombre es obligatorio.
            } @else if (nombreCtrl.hasError('minlength')) {
              Mínimo {{ nombreCtrl.getError('minlength').requiredLength }} letras
              (llevas {{ nombreCtrl.getError('minlength').actualLength }}).
            }
          </p>
        }
      </div>

      <div class="campo">
        <label for="email">Email</label>
        <input id="email" type="email" name="email" [(ngModel)]="email" #emailCtrl="ngModel" required email
          [attr.aria-invalid]="mostrar(emailCtrl.invalid, emailCtrl.touched, f.submitted)"
          aria-describedby="email-error" />
        @if (mostrar(emailCtrl.invalid, emailCtrl.touched, f.submitted)) {
          <p class="error" id="email-error">Escribe un email válido, como ana&#64;empresa.es.</p>
        }
      </div>

      <div class="campo">
        <label for="telefono">Teléfono (opcional)</label>
        <input id="telefono" name="telefono" [(ngModel)]="telefono" #telefonoCtrl="ngModel" pattern="[0-9]{9}" />
        @if (mostrar(telefonoCtrl.invalid, telefonoCtrl.touched, f.submitted)) {
          <p class="error">Deben ser 9 cifras, sin espacios.</p>
        }
      </div>

      <div class="campo">
        <label for="salario">Salario</label>
        <input id="salario" type="number" name="salario" [(ngModel)]="salario" #salarioCtrl="ngModel"
          required min="1000" max="10000" />
        @if (mostrar(salarioCtrl.invalid, salarioCtrl.touched, f.submitted)) {
          <p class="error">
            @if (salarioCtrl.hasError('min')) {
              Debe ser al menos {{ salarioCtrl.getError('min').min }}.
            } @else if (salarioCtrl.hasError('max')) {
              No puede superar {{ salarioCtrl.getError('max').max }}.
            } @else {
              El salario es obligatorio.
            }
          </p>
        }
      </div>

      <div class="campo">
        <label for="deptId">Departamento</label>
        <select id="deptId" name="deptId" [(ngModel)]="deptId" #deptCtrl="ngModel" required>
          <option [ngValue]="null">Elige uno</option>
          <option [ngValue]="20">VENTAS</option>
          <option [ngValue]="30">INFORMÁTICA</option>
        </select>
        @if (mostrar(deptCtrl.invalid, deptCtrl.touched, f.submitted)) {
          <p class="error">Elige un departamento.</p>
        }
      </div>

      <label class="fila">
        <input type="checkbox" name="acepta" [(ngModel)]="acepta" #aceptaCtrl="ngModel" required />
        Acepto el tratamiento de datos
      </label>
      @if (mostrar(aceptaCtrl.invalid, aceptaCtrl.touched, f.submitted)) {
        <p class="error">Debes aceptarlo para continuar.</p>
      }

      <button type="submit">Guardar</button>
    </form>
    @if (resultado()) {
      <pre>{{ resultado() }}</pre>
    }
  `,
})
export class AltaEmpleado {
  protected readonly nombre = signal('');
  protected readonly email = signal('');
  protected readonly telefono = signal('');
  protected readonly salario = signal<number | null>(null);
  protected readonly deptId = signal<number | null>(null);
  protected readonly acepta = signal(false);
  protected readonly resultado = signal('');

  protected mostrar(invalido: boolean | null, tocado: boolean | null, enviado: boolean) {
    return !!invalido && (!!tocado || enviado);
  }

  protected guardar(f: NgForm) {
    if (f.invalid) {
      return;
    }
    this.resultado.set(JSON.stringify(f.value));
  }
}

Formulario de alta de empleado con cada campo en rojo y su mensaje de error debajo

Figura 8. Tras pulsar Guardar con datos incorrectos: cada campo explica qué le pasa.

Puntos a destacar:

  • El botón no se deshabilita. Si desactivas Guardar mientras haya errores, el usuario no entiende por qué no puede pulsarlo, y un lector de pantalla puede ni llegar a él. Dejarlo activo y mostrar los errores al pulsarlo suele ser más claro. El if (f.invalid) return; sigue siendo necesario: (ngSubmit) se dispara aunque el formulario sea inválido.
  • Con f.submitted los errores aparecen todos a la vez al pulsar, incluso en campos que el usuario no llegó a tocar.
  • aria-invalid y aria-describedby hacen que un lector de pantalla anuncie el campo como erróneo y lea su mensaje. Sin ellos, el texto rojo no existe para quien no lo ve. La accesibilidad se trata a fondo en el módulo 21.
  • &#64; escribe una arroba en la plantilla. Una @ suelta iniciaría un bloque de control de flujo.
  • hasError('clave') evita comprobar errors?.['clave'], y getError('clave') da los detalles.
  • required en la casilla exige que esté marcada, porque el selector input[type=checkbox][required] aplica la versión para casillas.

Tras corregirlo y enviar, el resultado es:

{"nombre":"Ana","email":"ana@empresa","telefono":"","salario":2500,"deptId":20,"acepta":true}

Observa que ana@empresa ha pasado la validación, como avisaba la sección 8.

Un hijo no ve los cambios de un control 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) un componente sin changeDetection se comprueba en cada pasada, así que un hijo que recibe el NgModel entero sí se repinta. Si usas la LTS: ese código funciona, pero pasa igualmente [errores]="control.errors" para que siga funcionando cuando actualices a 22.

Repetir el bloque de mensajes en cada campo cansa, y lo natural es sacarlo a un componente. Ahora bien, esta versión, que parece la obvia, se queda congelada en Angular 22:

// Mal: recibe el NgModel entero
@Component({
  selector: 'app-error-malo',
  template: `<span>{{ control().errors ? 'CON ERROR' : 'sin error' }}</span>`,
})
export class ErrorMalo {
  readonly control = input.required<NgModel>();
}

// Bien: recibe los errores
@Component({
  selector: 'app-error-bueno',
  template: `<span>{{ errores() ? 'CON ERROR' : 'sin error' }}</span>`,
})
export class ErrorBueno {
  readonly errores = input.required<Record<string, unknown> | null>();
}
<input name="x" ngModel required #c="ngModel" />
<app-error-malo [control]="c" />
<app-error-bueno [errores]="c.errors" />

Al escribir en el campo vacío, para que deje de ser inválido, el resultado real es:

Angular 22.2:  CON ERROR | sin error     (el primero se ha quedado atrás)
Angular 21.2:  sin error | sin error

La causa es OnPush, el valor por defecto desde 22.0 (módulo 9): un hijo solo se repinta si cambia una de sus entradas o una señal que lea. El NgModel es siempre el mismo objeto, y errors es una propiedad corriente, no una señal. Para Angular, en ese hijo no ha pasado nada. En cambio, c.errors es un valor nuevo cada vez, y el hijo se entera. El ejercicio 2 construye el componente de errores completo.


11. Cuándo se actualiza un campo: ngModelOptions

Por defecto, el control se actualiza y se valida con cada tecla. [ngModelOptions] cambia eso, y además da otras opciones:

Opción Efecto
{ updateOn: 'change' } Con cada cambio. Es el valor por defecto
{ updateOn: 'blur' } Al salir del campo
{ updateOn: 'submit' } Al enviar el formulario
{ standalone: true } El campo no se registra en el formulario
{ name: 'x' } Da el nombre por esta vía, para componentes que usan name como entrada propia
<form #f="ngForm">
  <input name="blur" [(ngModel)]="alSalir" [ngModelOptions]="{ updateOn: 'blur' }" />
  <input type="checkbox" [(ngModel)]="soloActivos" [ngModelOptions]="{ standalone: true }" />
  <input name="desactivado" ngModel="oculto" [disabled]="true" />
</form>
<pre>{{ alSalir() }} {{ f.value | json }}</pre>

Escribiendo “hola” en el primer campo:

Antes de salir del campo:      { "blur": "" }
Después de salir:         hola { "blur": "hola" }

Fíjate en lo que no sale en f.value: ni la casilla, que es standalone, ni el campo desactivado. Un control deshabilitado no forma parte del valor ni de la validación. Es un error clásico: deshabilitas un campo, y al enviar f.value no lo lleva. Para deshabilitar, usa la entrada [disabled], nunca [attr.disabled], que solo tocaría el DOM y dejaría al control sin saberlo.

updateOn: 'blur' es útil cuando validar es caro, por ejemplo con un validador asíncrono: evita preguntar al servidor en cada tecla.

Para aplicar la opción a todo el formulario, usa [ngFormOptions] en el <form>:

<form #g="ngForm" [ngFormOptions]="{ updateOn: 'submit' }" (ngSubmit)="enviado.set(g.value.t)">
  <input name="t" ngModel required />
  <button>Enviar</button>
</form>
<pre>[{{ enviado() }}] {{ g.value | json }}</pre>
Escrita una "x", antes de enviar:  [] { "t": "" }
Tras enviar:                       [x] { "t": "x" }

12. Validadores propios

Cuando las reglas integradas no bastan, escribes una directiva que implementa Validator. Se registra en el token NG_VALIDATORS y NgModel la recoge junto a las demás. Si no recuerdas las directivas o los tokens de inyección, repasa los módulos 7 y 8.

Un validador con parámetros

Queremos rechazar nombres de departamento reservados:

import { Directive, OnChanges, SimpleChanges, input } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';

@Directive({
  selector: '[appProhibido]',
  providers: [{ provide: NG_VALIDATORS, useExisting: Prohibido, multi: true }],
})
export class Prohibido implements Validator, OnChanges {
  readonly appProhibido = input.required<string[]>();
  private avisar = () => {};

  validate(control: AbstractControl): ValidationErrors | null {
    const valor = String(control.value ?? '').trim().toUpperCase();
    const lista = this.appProhibido().map((p) => p.toUpperCase());
    return lista.includes(valor) ? { prohibido: { valor } } : null;
  }

  registerOnValidatorChange(fn: () => void) {
    this.avisar = fn;
  }

  ngOnChanges(cambios: SimpleChanges) {
    if (cambios['appProhibido']) {
      this.avisar();
    }
  }
}
<input name="dep" ngModel required [appProhibido]="reservados()" #dep="ngModel" />
@if (dep.hasError('prohibido')) {
  <p class="error">{{ dep.getError('prohibido').valor }} es un nombre reservado.</p>
}

Lo esencial:

  • validate devuelve null si todo va bien, o un objeto con las claves de error. Es lo que acabará en control.errors.
  • multi: true es obligatorio: NG_VALIDATORS es una lista a la que cada directiva añade su entrada. Sin él, sustituirías a los demás validadores.
  • useExisting reutiliza la instancia de la directiva, en lugar de crear otra.
  • registerOnValidatorChange resuelve un caso sutil. Angular vuelve a validar cuando cambia el valor, pero no sabe que ha cambiado un parámetro de tu validador. La función que te entrega sirve para pedírselo.

En la prueba, con “prueba” escrito y solo ADMIN reservado, no había error. Al pulsar un botón que añadía PRUEBA a la lista, apareció sin tocar el campo:

Antes de ampliar la lista:   (ningún error)
Después de ampliarla:        PRUEBA es un nombre reservado.

Validar varios campos a la vez

Una regla como “el fin no puede ser anterior al inicio” no pertenece a un campo, sino a la pareja. Se coloca en un ngModelGroup. El mecanismo es idéntico, pero validate recibe el grupo:

@Directive({
  selector: '[appRangoFechas]',
  providers: [{ provide: NG_VALIDATORS, useExisting: RangoFechas, multi: true }],
})
export class RangoFechas implements Validator {
  validate(grupo: AbstractControl): ValidationErrors | null {
    const { inicio, fin } = grupo.value as { inicio?: string; fin?: string };
    if (!inicio || !fin) {
      return null;
    }
    return inicio <= fin ? null : { rangoFechas: true };
  }
}
<fieldset ngModelGroup="fechas" appRangoFechas #fechas="ngModelGroup">
  <legend>Duración del proyecto</legend>
  <label>Inicio <input type="date" name="inicio" ngModel required /></label>
  <label>Fin <input type="date" name="fin" ngModel required /></label>
  @if (fechas.hasError('rangoFechas')) {
    <p class="error">El fin no puede ser anterior al inicio.</p>
  }
</fieldset>

Comparar las fechas como texto funciona porque el formato AAAA-MM-DD ordena igual alfabética y cronológicamente. El error está en el grupo, no en los campos: cada input por separado sigue siendo ng-valid, y por eso se pregunta a fechas y no a inicio.

Validadores asíncronos

Comprobar si un email ya está en uso requiere preguntar al servidor. Un validador asíncrono implementa AsyncValidator, se registra en NG_ASYNC_VALIDATORS y devuelve un Observable o una Promise.

import { HttpClient } from '@angular/common/http';
import { Directive, inject } from '@angular/core';
import {
  AbstractControl,
  AsyncValidator,
  NG_ASYNC_VALIDATORS,
  ValidationErrors,
} from '@angular/forms';
import { Observable, catchError, map, of, switchMap, timer } from 'rxjs';

const API = 'http://localhost:3000/api';

interface Pagina<T> {
  contenido: T[];
  total: number;
}

interface Empleado {
  empId: number;
  email: string;
}

@Directive({
  selector: '[appEmailLibre]',
  providers: [{ provide: NG_ASYNC_VALIDATORS, useExisting: EmailLibre, multi: true }],
})
export class EmailLibre implements AsyncValidator {
  private readonly http = inject(HttpClient);

  validate(control: AbstractControl): Observable<ValidationErrors | null> {
    const email = String(control.value ?? '').trim().toLowerCase();
    if (!email) {
      return of(null);
    }
    return timer(400).pipe(
      switchMap(() =>
        this.http.get<Pagina<Empleado>>(`${API}/empleados`, { params: { q: email, tamano: 5 } }),
      ),
      map((pagina) => (pagina.contenido.some((e) => e.email === email) ? { emailOcupado: true } : null)),
      catchError(() => of(null)),
    );
  }
}
<input type="email" name="email" ngModel required email appEmailLibre #mail="ngModel" />
@if (mail.pending) {
  <p class="pendiente">Comprobando…</p>
} @else if (mail.hasError('emailOcupado')) {
  <p class="error">Ese email ya está en uso.</p>
} @else if (mail.valid) {
  <p class="ok">Email disponible.</p>
}
<button [disabled]="f.invalid || f.pending">Guardar</button>

Formulario con el mensaje Comprobando en naranja y el botón Guardar deshabilitado mientras el validador espera la respuesta

Figura 9. Mientras llega la respuesta, el campo está en PENDING: ni válido ni inválido.

El mismo formulario con los tres errores propios a la vez: nombre reservado, fechas invertidas y email en uso

Figura 10. Los tres validadores propios en acción, con datos reales de la API.

Detalles que marcan la diferencia:

  • Los asíncronos solo se ejecutan si los síncronos han pasado. Con un email mal formado no se hace ninguna petición. El orden está en el código de Angular: _runAsyncValidator solo corre con el estado VALID o PENDING.
  • timer(400) es un debounce casero. Cada tecla cancela la validación anterior, de modo que si escribes deprisa, el timer nunca termina y no llega a salir la petición. El operador switchMap está en el módulo 14.
  • El Observable debe completarse. Angular espera a que termine. Con un valueChanges o un Subject que no se cierran, el control se queda en PENDING para siempre. Una petición HTTP y un timer de un disparo se completan solos.
  • catchError devuelve null para que un fallo de red no bloquee el formulario. Sigue validando el servidor al guardar. Según el caso, puedes preferir devolver un error.
  • f.valid es false durante PENDING, pero f.invalid también. Por eso el botón comprueba f.invalid || f.pending.

💡 Consejo: Un validador asíncrono lanza peticiones mientras el usuario escribe. Combínalo con [ngModelOptions]="{ updateOn: 'blur' }" y solo preguntarás al salir del campo.


13. Editar datos reales y errores del servidor

Falta el caso más común en una aplicación real: cargar un registro, editarlo, guardarlo y reaccionar si el servidor dice que no. La API EMPRESA responde a una validación fallida con un 422 y el error de cada campo:

{
  "status": 422,
  "mensaje": "Hay datos no válidos",
  "detalles": { "nombre": "Ya existe un departamento con ese nombre" }
}

La API necesita sesión para escribir. Aquí iniciamos sesión directamente, para centrarnos en el formulario. Lo correcto en una aplicación es un interceptor como el del módulo 12. La API se arranca con node server.mjs a partir de recursos/empresa-api.zip.

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Component, OnInit, inject, input, signal, viewChild } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

const API = 'http://localhost:3000/api';

interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
  presupuesto: number | null;
}

@Component({
  selector: 'app-editar-departamento',
  imports: [FormsModule],
  template: `
    <h2>Editar departamento {{ deptId() }}</h2>
    @if (cargando()) {
      <p>Cargando…</p>
    }
    <form #f="ngForm" (ngSubmit)="guardar()">
      <label>Nombre
        <input name="nombre" [(ngModel)]="nombre" required #nombreCtrl="ngModel" />
      </label>
      @if (nombreCtrl.hasError('servidor')) {
        <p class="error">{{ nombreCtrl.getError('servidor') }}</p>
      }
      <label>Localidad <input name="localidad" [(ngModel)]="localidad" /></label>
      <label>Presupuesto
        <input type="number" name="presupuesto" [(ngModel)]="presupuesto" min="0" />
      </label>
      <button type="submit" [disabled]="f.invalid || f.pristine || guardando()">
        {{ guardando() ? 'Guardando…' : 'Guardar cambios' }}
      </button>
    </form>
    <p>{{ mensaje() }}</p>
  `,
})
export class EditarDepartamento implements OnInit {
  private readonly http = inject(HttpClient);
  private readonly formulario = viewChild.required(NgForm);

  readonly deptId = input(30);

  protected readonly nombre = signal('');
  protected readonly localidad = signal('');
  protected readonly presupuesto = signal<number | null>(null);
  protected readonly cargando = signal(true);
  protected readonly guardando = signal(false);
  protected readonly mensaje = signal('');

  private token = '';

  ngOnInit() {
    this.http.get<Departamento>(`${API}/departamentos/${this.deptId()}`).subscribe((d) => {
      this.nombre.set(d.nombre);
      this.localidad.set(d.localidad);
      this.presupuesto.set(d.presupuesto);
      this.cargando.set(false);
    });
    this.http
      .post<{ token: string }>(`${API}/auth/login`, { email: 'ana.garcia@empresa.es', password: 'secreto' })
      .subscribe((r) => (this.token = r.token));
  }

  protected guardar() {
    const datos = { nombre: this.nombre(), localidad: this.localidad(), presupuesto: this.presupuesto() };
    this.guardando.set(true);
    this.mensaje.set('');
    this.http
      .put<Departamento>(`${API}/departamentos/${this.deptId()}`, datos, {
        headers: { Authorization: `Bearer ${this.token}` },
      })
      .subscribe({
        next: (d) => {
          this.formulario().resetForm({ nombre: d.nombre, localidad: d.localidad, presupuesto: d.presupuesto });
          this.mensaje.set('Cambios guardados.');
          this.guardando.set(false);
        },
        error: (e: HttpErrorResponse) => {
          if (e.status === 422) {
            this.pintarErrores(e.error.detalles);
          } else {
            this.mensaje.set(e.error?.mensaje ?? 'No se pudo guardar.');
          }
          this.guardando.set(false);
        },
      });
  }

  private pintarErrores(detalles: Record<string, string>) {
    for (const [campo, texto] of Object.entries(detalles)) {
      this.formulario().controls[campo]?.setErrors({ servidor: texto });
    }
  }
}

Formulario de edición de departamento con el error Ya existe un departamento con ese nombre bajo el campo Nombre y el botón deshabilitado

Figura 11. El error viene del servidor, pero se muestra como cualquier otro error del campo.

El recorrido real contra la API, con deptId 30 y el nombre repetido VENTAS:

Al cargar:            nombre=INFORMÁTICA  botón deshabilitado  dirty=false
Guardar con VENTAS:   Ya existe un departamento con ese nombre  dirty=true
Al corregir el campo: el error desaparece
Guardar con SISTEMAS: Cambios guardados.  dirty=false  botón deshabilitado

Lo interesante:

  • Los datos cargados no ensucian el formulario. Tras nombre.set(...) sigue dirty=false, porque el cambio no viene del usuario. Por eso f.pristine sirve para deshabilitar el botón hasta que haya algo que guardar.
  • setErrors mete el error en el control, con la clave que quieras. El campo pasa a inválido y se ve igual que un error local.
  • El error se borra solo: cuando el usuario escribe, Angular recalcula errors con los validadores, y servidor no es uno de ellos.
  • resetForm(valores) tras guardar deja lo guardado como nuevo punto de partida, y dirty vuelve a false.
  • El estado del botón no se calcula a mano porque guardando es una señal.

Por qué la vista se entera, y cuándo no

Con OnPush y sin zone.js, setErrors dentro de una respuesta HTTP no marca nada por sí mismo, y aun así el error aparece. ¿Por qué?

Hay dos motivos. El primero: el código anterior hace guardando.set(false), y esa señal la lee la plantilla. El segundo es más interesante. Las directivas NgControlStatus y NgControlStatusGroup, las que ponen las clases ng-*, leen internamente señales con el estado de cada control (_status, _touched, _pristine). Cuando el estado cambia de verdad, esas señales avisan y la vista se repinta. Se nota en este experimento, sin ninguna señal propia:

protected marcar() {
  setTimeout(() => this.form().controls['n'].setErrors({ servidor: 'x' }), 50);
}
<pre>{{ c.hasError('servidor') ? 'SE VE' : 'NO SE VE' }} {{ c.status }}</pre>
Estado antes de setErrors Estado después Resultado
VALID INVALID SE VE: el estado ha cambiado
INVALID (required) INVALID NO SE VE: el estado es el mismo y no avisa nadie

El resultado es igual en Angular 21 y en 22.

⚠️ Importante: Lo que lees del formulario en la plantilla no son señales. Se refresca como efecto secundario cuando cambia el estado o cuando un evento toca la vista. Si un cambio asíncrono debe verse siempre, cambia también una señal tuya, como hace guardando en el ejemplo.

Quitar controles y f.value en pantalla 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21), en modo desarrollo, mostrar {{ f.value | json }} mientras se quita un control puede lanzar además NG0100 en la consola; en 22.2 el valor solo se queda atrás, sin aviso. Si usas la LTS: no te preocupes por ese error si solo es una ayuda de depuración, y usa f.value al enviar, que siempre está al día.

Esa misma limitación se nota al quitar un campo de una lista como la de la sección 14, si su desaparición no cambia el estado del formulario. Los controles se dan de baja en una microtarea después de pintar, y si el estado sigue igual nadie pide repintar. Lo verás con {{ f.value | json }}, muy habitual como ayuda para depurar: al quitar un teléfono válido, la pantalla sigue mostrándolo. Si el campo quitado era el inválido, el estado cambia a VALID y la vista sí se actualiza. El valor de f.value es correcto en los dos casos, solo que la pantalla va un paso por detrás.


14. Listas dinámicas

Se pueden repetir campos con @for, con dos condiciones: cada campo necesita un name único y track debe usar un identificador estable.

import { JsonPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-telefonos',
  imports: [FormsModule, JsonPipe],
  template: `
    <h2>Teléfonos de contacto</h2>
    <form #f="ngForm">
      @for (t of telefonos(); track t.id) {
        <div class="fila">
          <input [name]="'telefono' + t.id" [(ngModel)]="t.numero" required pattern="[0-9]{9}" />
          <button type="button" class="quitar" (click)="quitar(t.id)">Quitar</button>
        </div>
      }
      <button type="button" (click)="anadir()">Añadir teléfono</button>
    </form>
    <pre>{{ f.value | json }} {{ f.status }}</pre>
  `,
})
export class Telefonos {
  private siguienteId = 2;
  protected readonly telefonos = signal([{ id: 1, numero: '600100100' }]);

  protected anadir() {
    this.telefonos.update((lista) => [...lista, { id: this.siguienteId++, numero: '' }]);
  }

  protected quitar(id: number) {
    this.telefonos.update((lista) => lista.filter((t) => t.id !== id));
  }
}

Lista de tres teléfonos con botones Quitar, el botón Añadir teléfono y debajo el valor del formulario con claves telefono1, telefono2 y telefono3

Figura 12. Cada fila es un control con su clave, que el formulario añade y quita al vuelo.

Las claves salen del id, no del índice. Con $index, al quitar una fila central las siguientes cambiarían de nombre, y el formulario tendría que recrear sus controles, perdiendo el estado.

Fíjate también en [(ngModel)]="t.numero": aquí mutamos el objeto de la lista, lo que es aceptable si solo te interesa el valor al enviar. Si algo calculado depende de los datos, actualiza la señal con un objeto nuevo, como en el ejercicio 6.

Dos campos con el mismo name

Si en lugar de nombres únicos usas name="telefono" para todos, no hay error alguno, pero comparten un único control:

Al cargar, con valores "1" y "2":   { "tel": "2" }   u=2      v=2
Al escribir en el primero:          { "tel": "nuevo" } u=nuevo v=2

El segundo campo pisa el valor inicial del primero, y después los dos se desincronizan. Cuando una lista “pierde” datos en un formulario por plantilla, mira esto primero.

📘 Concepto: Cuando un formulario es en esencia una lista que crece y mengua, los formularios por plantilla empiezan a costar. Es la señal para pasar a FormArray (módulo 17) o a los arrays de Signal Forms (módulo 18).


15. ngModel en controles propios con señales 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21): el mismo código falla al ejecutarse con NG01203: No value accessor for form control name. Si usas la LTS: implementa ControlValueAccessor en tu componente, como se explica en el módulo 17.

Hasta ahora, para usar ngModel sobre un componente propio había que escribir un ControlValueAccessor. Desde Angular 22, basta con cumplir el contrato de los controles de Signal Forms: una entrada value creada con model(). El contrato es la interfaz FormValueControl, y aquí usamos su salida opcional touch para avisar de que el usuario ha pasado por el control.

import { JsonPipe } from '@angular/common';
import { Component, model, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormValueControl } from '@angular/forms/signals';

@Component({
  selector: 'app-valoracion',
  template: `
    @for (n of [1, 2, 3]; track n) {
      <button type="button" (click)="value.set(n)" (blur)="touch.emit()">{{ n }}</button>
    }
    <span>{{ value() }}</span>
  `,
})
export class Valoracion implements FormValueControl<number> {
  readonly value = model(0);
  readonly touch = output<void>();
}

@Component({
  selector: 'app-control-senal',
  imports: [FormsModule, Valoracion, JsonPipe],
  template: `
    <form #f="ngForm">
      <app-valoracion name="nota" [(ngModel)]="nota" />
    </form>
    <pre>{{ nota() }} {{ f.value | json }}</pre>
  `,
})
export class ControlSenal {
  protected readonly nota = signal(2);
}

Salida real, y clases del elemento app-valoracion:

Al cargar:            2 { "nota": 2 }
Al pulsar el 3:       3 { "nota": 3 }    ng-untouched ng-valid ng-dirty
Al salir del botón:                      ng-valid ng-dirty ng-touched

Es un control de verdad para el formulario: cuenta como dirty al cambiar y como touched al recibir touch. La ventaja es que el mismo componente sirve para Signal Forms, así que lo aprovecharás en el módulo 18.

Si un elemento tiene a la vez un ControlValueAccessor y el contrato de señales, gana el primero. El atributo ngNoCva en el elemento hace que Angular ignore los accessors integrados y use el de señales.


16. Probar un formulario por plantilla

Las pruebas se explican en el módulo 26, pero los formularios por plantilla tienen una particularidad que conviene conocer ya. Como los controles se registran en una microtarea, hay que esperar antes de leer el formulario. await fixture.whenStable() lo hace.

import { TestBed } from '@angular/core/testing';
import { NgForm } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { describe, expect, it } from 'vitest';
import { AltaEmpleado } from './alta-empleado';

describe('AltaEmpleado', () => {
  it('empieza no válido y pasa a válido al rellenarlo', async () => {
    const fixture = TestBed.createComponent(AltaEmpleado);
    await fixture.whenStable();

    const elemento: HTMLElement = fixture.nativeElement;
    const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);
    expect(form.valid).toBe(false);

    const escribir = (selector: string, valor: string) => {
      const input = elemento.querySelector<HTMLInputElement>(selector)!;
      input.value = valor;
      input.dispatchEvent(new Event('input'));
    };
    escribir('#nombre', 'Ana');
    escribir('#email', 'ana@empresa.es');
    escribir('#salario', '2000');

    const select = elemento.querySelector<HTMLSelectElement>('#deptId')!;
    select.selectedIndex = 1;
    select.dispatchEvent(new Event('change'));

    const check = elemento.querySelector<HTMLInputElement>('[name=acepta]')!;
    check.click();
    await fixture.whenStable();

    expect(form.valid).toBe(true);
    expect(form.value.salario).toBe(2000);
  });
});
 Test Files  1 passed (1)
      Tests  1 passed (1)

La prueba escribe como lo haría un usuario: cambia value y lanza el evento input. Si solo cambias value, Angular no se entera, porque escucha el evento y no la propiedad.


17. Errores frecuentes

Síntoma Causa Solución
NG8002: Can't bind to 'ngModel' Falta FormsModule en imports Impórtalo en ese componente
NG8003: No directive found with exportAs 'ngForm' Igual que el anterior Impórtalo en ese componente
NG01352 Campo sin name dentro de <form> Añade name o { standalone: true }
NG5002: Unsupported expression in a two-way binding [(ngModel)]="nombre()" Quita los paréntesis
TS2551: Property 'invalid' does not exist on type 'HTMLInputElement' #c en lugar de #c="ngModel" Añade ="ngModel"
NG01203: No value accessor ngModel sobre un elemento que no sabe usarlo Implementa el contrato de señales o ControlValueAccessor
NG01354 en la consola Hijo sin viewProviders Comparte ControlContainer
La página se recarga al enviar Usas (submit) sin FormsModule Importa FormsModule y usa (ngSubmit)
Un botón envía sin querer <button> sin type Añade type="button"
El formulario se envía siendo inválido (ngSubmit) no comprueba nada if (f.invalid) return;
Falta un campo en f.value Está deshabilitado o es standalone Usa getRawValue en f.form o tus señales
El campo sale en rojo al abrir Regla CSS sobre .ng-invalid a secas Exige también .ng-touched
El select aparece vacío Objetos distintos con igual contenido [compareWith] o guarda el id
Tu número llega como texto [value] en una <option> Usa [ngValue]
Tras reiniciar, TypeError con null reset() escribe null en tus señales resetForm({ ...valores })
Campos que se pisan en una lista Mismo name Nombre único a partir del id
El control se queda en PENDING El Observable no se completa Usa take(1) o una fuente que termine
form.controls vacío en ngAfterViewInit Se registran en una microtarea Espera o usa [ngModel] para el valor inicial
Un hijo muestra errores antiguos OnPush: el NgModel es el mismo objeto Pásale [errores]="c.errors"
Un error de setErrors no aparece El estado no ha cambiado y nada repinta Cambia una señal a la vez

Depurar

Lo más rápido es dejar temporalmente esto al final del formulario:

<pre>{{ f.value | json }} {{ f.status }} {{ f.dirty }}</pre>

Si sospechas de un campo, mira sus clases en el inspector del navegador: ng-invalid te dice que Angular lo ve mal, y #c="ngModel" con {{ c.errors | json }} te dice por qué. Las DevTools de Angular muestran además las directivas de cada elemento.


18. Cuándo usar formularios por plantilla

Encajan bien cuando el formulario es sencillo, sus reglas caben en atributos y no cambia de forma: inicio de sesión, contacto, filtros de una tabla, ajustes con unas pocas casillas o un campo suelto con [(ngModel)].

Se quedan cortos cuando hay muchos campos dinámicos, reglas que dependen unas de otras, o quieres probar la lógica sin renderizar. Sus límites son estructurales:

  • La lógica vive en el HTML, y probarla exige una plantilla.
  • f.value es de tipo any.
  • El modelo existe de forma asíncrona, no al crear el componente.
  • Los validadores propios exigen una directiva cada uno.
  • Una lista dinámica se complica enseguida.

Signal Forms como tercera opción 🔄 v22.0

🔄 Cambia en Angular 22.0. En la LTS (Angular 21) Signal Forms existe pero es experimental y su API puede cambiar. Si usas la LTS: para un proyecto nuevo, prefiere formularios por plantilla o reactivos, que son estables en las dos versiones.

Si necesitas… Elige
Un formulario pequeño, deprisa Por plantilla
Mantener código existente Lo que ya use ese código
Lógica compleja, FormArray, valueChanges con RxJS Reactivos
Un formulario nuevo, con señales y tipos Signal Forms

No hace falta casarse con una opción: en una misma aplicación pueden convivir, formulario a formulario. Lo que no debes hacer es mezclar ngModel y formControl en el mismo campo.


19. Ejercicios

Ejercicio 1: Alta de producto

Crea un formulario de alta de producto con ngModel a secas, sin señales por campo. Campos: nombre (obligatorio, mínimo 3 letras), categoriaId (select cargado desde GET /api/categorias, obligatorio), precio (obligatorio, no negativo), stock (por defecto 0, no negativo) y descatalogado (casilla, por defecto desmarcada). Muestra los errores al salir del campo o al intentar enviar, y al enviar con datos válidos muestra f.value como JSON.

Ver solución
import { HttpClient } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

const API = 'http://localhost:3000/api';

interface Categoria {
  categoriaId: number;
  nombre: string;
}

interface Lista<T> {
  contenido: T[];
}

@Component({
  selector: 'app-alta-producto',
  imports: [FormsModule],
  template: `
    <h2>Nuevo producto</h2>
    <form #f="ngForm" (ngSubmit)="guardar(f)">
      <label>Nombre
        <input name="nombre" ngModel required minlength="3" #nombre="ngModel" />
      </label>
      @if (nombre.invalid && (nombre.touched || f.submitted)) {
        <p class="error">El nombre necesita al menos 3 letras.</p>
      }
      <label>Categoría
        <select name="categoriaId" [ngModel]="null" required #categoria="ngModel">
          <option [ngValue]="null">Elige una</option>
          @for (c of categorias(); track c.categoriaId) {
            <option [ngValue]="c.categoriaId">{{ c.nombre }}</option>
          }
        </select>
      </label>
      @if (categoria.invalid && (categoria.touched || f.submitted)) {
        <p class="error">Elige una categoría.</p>
      }
      <label>Precio
        <input type="number" name="precio" ngModel required min="0" step="0.01" #precio="ngModel" />
      </label>
      @if (precio.invalid && (precio.touched || f.submitted)) {
        <p class="error">El precio es obligatorio y no puede ser negativo.</p>
      }
      <label>Stock
        <input type="number" name="stock" [ngModel]="0" required min="0" #stock="ngModel" />
      </label>
      @if (stock.invalid && (stock.touched || f.submitted)) {
        <p class="error">El stock no puede ser negativo.</p>
      }
      <label class="fila"><input type="checkbox" name="descatalogado" [ngModel]="false" /> Descatalogado</label>
      <button type="submit">Guardar</button>
    </form>
    <pre>{{ enviado() }}</pre>
  `,
})
export class AltaProducto {
  private readonly http = inject(HttpClient);
  protected readonly categorias = signal<Categoria[]>([]);
  protected readonly enviado = signal('');

  constructor() {
    this.http.get<Lista<Categoria>>(`${API}/categorias`).subscribe((r) => {
      this.categorias.set(r.contenido);
    });
  }

  protected guardar(f: NgForm) {
    if (f.valid) {
      this.enviado.set(JSON.stringify(f.value));
    }
  }
}

Al enviar vacío aparecen 3 errores (nombre, categoría y precio; el stock ya vale 0). Rellenándolo:

{"nombre":"Teclado","categoriaId":2,"precio":19.9,"stock":0,"descatalogado":false}

[ngModel]="null" y [ngModel]="0" dan el valor inicial, y como 0 y false no son texto, deben ir entre corchetes. ngModel="0" daría la cadena '0'.

Ejercicio 2: Componente de errores

Saca los mensajes a un componente ErrorCampo que reciba los errores y si debe mostrarlos, y que traduzca cada clave a un texto en español, incluidos los detalles como la longitud mínima. Ten en cuenta la sección 10: debe funcionar con OnPush.

Ver solución
import { Component, computed, input } from '@angular/core';
import { FormsModule, ValidationErrors } from '@angular/forms';

const MENSAJES: Record<string, (d: never) => string> = {
  required: () => 'Este campo es obligatorio.',
  email: () => 'El email no es válido.',
  minlength: (d: { requiredLength: number }) => `Mínimo ${d.requiredLength} caracteres.`,
  min: (d: { min: number }) => `Debe ser al menos ${d.min}.`,
};

@Component({
  selector: 'app-error-campo',
  template: `
    @if (visible() && mensaje()) {
      <p class="error" role="alert">{{ mensaje() }}</p>
    }
  `,
})
export class ErrorCampo {
  readonly errores = input.required<ValidationErrors | null>();
  readonly visible = input.required<boolean>();

  protected readonly mensaje = computed(() => {
    const errores = this.errores();
    if (!errores) {
      return '';
    }
    const [clave, detalle] = Object.entries(errores)[0];
    return MENSAJES[clave]?.(detalle as never) ?? `Error: ${clave}`;
  });
}

@Component({
  selector: 'app-demo-error',
  imports: [FormsModule, ErrorCampo],
  template: `
    <form #f="ngForm">
      <label>Oficio <input name="oficio" ngModel required minlength="3" #oficio="ngModel" /></label>
      <app-error-campo [errores]="oficio.errors" [visible]="!!oficio.touched" />
      <label>Email <input name="email" ngModel required email #email="ngModel" /></label>
      <app-error-campo [errores]="email.errors" [visible]="!!email.touched" />
    </form>
  `,
})
export class DemoError {}

Al salir del campo vacío se lee “Este campo es obligatorio.” y al escribir “ab” cambia a “Mínimo 3 caracteres.”. Funciona con OnPush porque las dos entradas reciben valores, no el NgModel: oficio.errors es un objeto nuevo en cada validación, y eso activa la entrada y el computed. role="alert" hace que los lectores de pantalla lean el mensaje en cuanto aparece.

Ejercicio 3: required que no admite espacios

Comprueba que un campo con required da por bueno ' '. Escribe una directiva appConTexto que lo rechace, y haz que un campo vacío no dé este error (de eso ya se ocupa required).

Ver solución
import { Component, Directive } from '@angular/core';
import {
  AbstractControl,
  FormsModule,
  NG_VALIDATORS,
  ValidationErrors,
  Validator,
} from '@angular/forms';

@Directive({
  selector: '[appConTexto]',
  providers: [{ provide: NG_VALIDATORS, useExisting: ConTexto, multi: true }],
})
export class ConTexto implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    const valor = control.value;
    if (typeof valor !== 'string' || valor.length === 0) {
      return null;
    }
    return valor.trim().length > 0 ? null : { soloEspacios: true };
  }
}

@Component({
  selector: 'app-demo-texto',
  imports: [FormsModule, ConTexto],
  template: `
    <form>
      <input name="a" ngModel required #a="ngModel" />
      <input name="b" ngModel required appConTexto #b="ngModel" />
    </form>
    <pre>solo required: {{ a.status }}; con directiva: {{ b.status }} {{ b.hasError('soloEspacios') }}</pre>
  `,
})
export class DemoTexto {}

Con tres espacios en cada campo:

solo required: VALID; con directiva: INVALID true

Que el validador devuelva null con el campo vacío sigue la convención de Angular: cada regla se ocupa de una sola cosa, y required decide si el campo es obligatorio. Así appConTexto también sirve en campos opcionales.

Ejercicio 4: Confirmar contraseña

Crea un formulario de cambio de contraseña con los campos nueva (mínimo 6 caracteres) y repetida. Escribe un validador de grupo appCoinciden que reciba los dos nombres de campo, y muestra el error cuando el usuario haya salido del segundo campo.

Ver solución
import { Component, Directive, input, signal } from '@angular/core';
import {
  AbstractControl,
  FormsModule,
  NG_VALIDATORS,
  ValidationErrors,
  Validator,
} from '@angular/forms';

@Directive({
  selector: '[appCoinciden]',
  providers: [{ provide: NG_VALIDATORS, useExisting: Coinciden, multi: true }],
})
export class Coinciden implements Validator {
  readonly appCoinciden = input.required<[string, string]>();

  validate(grupo: AbstractControl): ValidationErrors | null {
    const [a, b] = this.appCoinciden();
    const primero = grupo.get(a)?.value;
    const segundo = grupo.get(b)?.value;
    if (!primero || !segundo) {
      return null;
    }
    return primero === segundo ? null : { noCoinciden: true };
  }
}

@Component({
  selector: 'app-cambio-clave',
  imports: [FormsModule, Coinciden],
  template: `
    <h2>Cambiar contraseña</h2>
    <form #f="ngForm" (ngSubmit)="hecho.set(true)">
      <div ngModelGroup="claves" [appCoinciden]="['nueva', 'repetida']" #claves="ngModelGroup">
        <label>Nueva
          <input type="password" name="nueva" ngModel required minlength="6" autocomplete="new-password" />
        </label>
        <label>Repítela
          <input type="password" name="repetida" ngModel required autocomplete="new-password" #rep="ngModel" />
        </label>
        @if (claves.hasError('noCoinciden') && rep.touched) {
          <p class="error">Las contraseñas no coinciden.</p>
        }
      </div>
      <button [disabled]="f.invalid">Cambiar</button>
    </form>
    <pre>{{ f.status }} {{ hecho() }}</pre>
  `,
})
export class CambioClave {
  protected readonly hecho = signal(false);
}
secreto1 / secreto2:  Las contraseñas no coinciden.  INVALID false
secreto1 / secreto1:  (sin error)                    VALID true

Validar en el grupo tiene una ventaja: el error se recalcula al cambiar cualquiera de los dos campos. Un validador puesto solo en repetida no se enteraría si después cambias nueva. Y no hace falta registerOnValidatorChange porque los nombres no cambian.

Ejercicio 5: Inicio de sesión

Crea un formulario de inicio de sesión contra POST /api/auth/login. Guarda el token en sessionStorage, muestra “Email o contraseña incorrectos.” ante un 401 y deshabilita el botón mientras se envía. Al entrar, reinicia el formulario. Credenciales de prueba: ana.garcia@empresa.es / secreto.

Ver solución
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

const API = 'http://localhost:3000/api';

interface RespuestaLogin {
  token: string;
  usuario: { nombre: string; rol: string };
}

@Component({
  selector: 'app-login',
  imports: [FormsModule],
  template: `
    <h2>Iniciar sesión</h2>
    <form #f="ngForm" (ngSubmit)="entrar(f)">
      <label>Email
        <input type="email" name="email" ngModel required email autocomplete="username" />
      </label>
      <label>Contraseña
        <input type="password" name="password" ngModel required autocomplete="current-password" />
      </label>
      @if (error()) {
        <p class="error" role="alert">{{ error() }}</p>
      }
      <button type="submit" [disabled]="f.invalid || enviando()">Entrar</button>
    </form>
    @if (usuario(); as u) {
      <p class="ok">Hola, {{ u.nombre }} ({{ u.rol }})</p>
    }
  `,
})
export class Login {
  private readonly http = inject(HttpClient);
  protected readonly enviando = signal(false);
  protected readonly error = signal('');
  protected readonly usuario = signal<RespuestaLogin['usuario'] | null>(null);

  protected entrar(f: NgForm) {
    if (f.invalid) {
      return;
    }
    this.enviando.set(true);
    this.error.set('');
    this.http.post<RespuestaLogin>(`${API}/auth/login`, f.value).subscribe({
      next: (r) => {
        sessionStorage.setItem('token', r.token);
        this.usuario.set(r.usuario);
        this.enviando.set(false);
        f.resetForm();
      },
      error: (e: HttpErrorResponse) => {
        this.error.set(e.status === 401 ? 'Email o contraseña incorrectos.' : 'No se pudo conectar.');
        this.enviando.set(false);
      },
    });
  }
}
Con contraseña errónea:  Email o contraseña incorrectos.
Con la correcta:         Hola, Ana García (ADMIN)

Es un caso ideal para esta forma de trabajar: dos campos, reglas simples, y f.value ya tiene la forma exacta que pide la API. Los autocomplete ayudan a los gestores de contraseñas. El sessionStorage sirve para este ejercicio; el almacenamiento de sesión correcto llega en el módulo 29.

Ejercicio 6: Líneas de pedido

Crea un formulario de pedido con líneas que se puedan añadir y quitar. Cada línea tiene un producto y una cantidad mínima de 1. Calcula el total con un computed, y usa actualizaciones inmutables de la señal para que el total siempre esté al día. Debe quedar siempre al menos una línea.

Ver solución
import { CurrencyPipe } from '@angular/common';
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Linea {
  id: number;
  productoId: number | null;
  cantidad: number;
}

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

@Component({
  selector: 'app-lineas-pedido',
  imports: [FormsModule, CurrencyPipe],
  template: `
    <h2>Nuevo pedido</h2>
    <form #f="ngForm" (ngSubmit)="enviar()">
      @for (l of lineas(); track l.id) {
        <div class="fila">
          <select [name]="'producto' + l.id" required
            [ngModel]="l.productoId" (ngModelChange)="cambiar(l.id, { productoId: $event })">
            <option [ngValue]="null">Producto</option>
            @for (p of productos; track p.productoId) {
              <option [ngValue]="p.productoId">{{ p.nombre }}</option>
            }
          </select>
          <input type="number" [name]="'cantidad' + l.id" required min="1"
            [ngModel]="l.cantidad" (ngModelChange)="cambiar(l.id, { cantidad: $event })" />
          <button type="button" (click)="quitar(l.id)" [disabled]="lineas().length === 1">Quitar</button>
        </div>
      }
      <button type="button" (click)="anadir()">Añadir línea</button>
      <p>Total: {{ total() | currency: 'EUR' }}</p>
      <button type="submit" [disabled]="f.invalid">Crear pedido</button>
    </form>
    <pre>{{ enviado() }}</pre>
  `,
})
export class LineasPedido {
  protected readonly productos: Producto[] = [
    { productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899 },
    { productoId: 2, nombre: 'Ratón inalámbrico', precio: 25 },
  ];
  private siguienteId = 2;
  protected readonly lineas = signal<Linea[]>([{ id: 1, productoId: null, cantidad: 1 }]);
  protected readonly enviado = signal('');

  protected readonly total = computed(() =>
    this.lineas().reduce((suma, l) => {
      const producto = this.productos.find((p) => p.productoId === l.productoId);
      return suma + (producto?.precio ?? 0) * (l.cantidad ?? 0);
    }, 0),
  );

  protected anadir() {
    this.lineas.update((ls) => [...ls, { id: this.siguienteId++, productoId: null, cantidad: 1 }]);
  }

  protected quitar(id: number) {
    this.lineas.update((ls) => ls.filter((l) => l.id !== id));
  }

  protected cambiar(id: number, cambios: Partial<Linea>) {
    this.lineas.update((ls) => ls.map((l) => (l.id === id ? { ...l, ...cambios } : l)));
  }

  protected enviar() {
    const lineas = this.lineas().map(({ productoId, cantidad }) => ({ productoId, cantidad }));
    this.enviado.set(JSON.stringify({ clienteId: 1001, lineas }));
  }
}

Pedido con dos líneas, portátil y ratón, con sus cantidades y un total calculado de 1873 euros

Figura 13. El total se recalcula con cada cambio porque lineas se actualiza de forma inmutable.

Dos portátiles y tres ratones dan 2 * 899 + 3 * 25 = 1873:

Total: €1,873.00
{"clienteId":1001,"lineas":[{"productoId":1,"cantidad":2},{"productoId":2,"cantidad":3}]}

El formato €1,873.00 es el de la configuración regional por defecto, en-US. Para verlo como 1.873,00 €, configura LOCALE_ID como en el módulo 7.

El dato se lee de la señal lineas, no de f.value: así no hay que reconstruir las líneas a partir de las claves producto1, cantidad1… Si borras una línea vacía, el formulario pasa de INVALID a VALID y el botón se habilita, porque ese cambio de estado sí repinta la vista. Fíjate en lo laborioso que resulta todo esto: con un FormArray será mucho más natural.

Ejercicio 7: Avisar de cambios sin guardar

Protege la ruta de un formulario con un guard canDeactivate, para que pregunte antes de salir si hay cambios sin guardar. Usa f.dirty para saberlo. Los guards están en el módulo 11.

Ver solución
import { Component, viewChild } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { CanDeactivateFn, RouterLink } from '@angular/router';

@Component({
  selector: 'app-editar-nota',
  imports: [FormsModule, RouterLink],
  template: `
    <form #f="ngForm">
      <label>Nota <input name="nota" ngModel /></label>
    </form>
    <a routerLink="/inicio">Salir</a>
  `,
})
export class EditarNota {
  readonly formulario = viewChild.required(NgForm);

  tieneCambios() {
    return !!this.formulario().dirty;
  }
}

export const avisarCambios: CanDeactivateFn<EditarNota> = (componente) =>
  !componente.tieneCambios() || confirm('Tienes cambios sin guardar. ¿Quieres salir?');
// app.routes.ts
{ path: 'nota', component: EditarNota, canDeactivate: [avisarCambios] },

Sin cambios, el enlace navega directamente. Con cambios aparece el diálogo, y al cancelar el formulario sigue en pantalla. Tras guardar, llama a resetForm(valores) para que dirty vuelva a false y el aviso deje de saltar.

Ejercicio 8 (propuesto): Filtros de empleados

Crea una barra de filtros para la lista de empleados con un campo de búsqueda, un select de departamento y una casilla “solo con comisión”, todos con ngModel fuera de un <form>. Usa httpResource (módulo 13) para que la lista se recargue con cada cambio, y updateOn: 'blur' en el campo de búsqueda.

Ejercicio 9 (propuesto): Email libre al editar

Amplía EmailLibre con una entrada empIdActual, de modo que al editar un empleado no le dé error su propio email. Debe usar registerOnValidatorChange por si cambia el empleado.

Ejercicio 10 (propuesto): Pedido con dirección reutilizable

Usa FormDireccion en dos formularios distintos, y añade al subcomponente una entrada grupo que permita llamar al grupo facturacion en uno y envio en el otro. Incluye en el formulario una casilla “usar la misma dirección” que deshabilite uno de los grupos, y explica qué pasa con su valor en f.value.


Resumen

Concepto API Idea clave
Activar FormsModule Todo <form> pasa a ser un NgForm
Enlace de un campo ngModel, [ngModel], [(ngModel)] Con señales, sin paréntesis; name es obligatorio en un <form>
Formulario #f="ngForm", (ngSubmit) f.value, f.valid, f.submitted; no impide enviar si es inválido
Campo #c="ngModel" c.errors, c.hasError(), c.getError()
Momento de creación Microtarea En ngAfterViewInit el formulario aún está vacío
Reinicio resetForm(valores) reset() no borra submitted; sin valores se escribe null
Opciones [value], [ngValue], [compareWith] [ngValue] conserva el tipo
Grupos ngModelGroup Valor anidado y validación conjunta
Subformularios viewProviders con ControlContainer Sin él, el campo se queda fuera del formulario
Validación required, minlength, maxlength, pattern, email, min, max Un campo vacío solo incumple required
Estados pristine/dirty, untouched/touched, valid/invalid/pending Tres ejes independientes
Clases ng-valid, ng-invalid, ng-dirty, ng-touched, ng-pending, ng-submitted Combínalas: .ng-invalid.ng-touched
Actualización [ngModelOptions], [ngFormOptions] updateOn, standalone, name
Campos deshabilitados [disabled] No están en f.value ni se validan
Validador propio Validator, NG_VALIDATORS Directiva con multi: true
Validador con parámetros registerOnValidatorChange Avisa cuando cambia un parámetro
Validador asíncrono AsyncValidator, NG_ASYNC_VALIDATORS El Observable debe completarse
Errores del servidor control.setErrors() Se borran al volver a validar
Listas @for con [name] único Mismo name, mismo control
Hijos con OnPush 🔄 v22.0 [errores]="c.errors" Pasa valores, no el NgModel
Controles con señales 🆕 v22.0 FormValueControl, model(), touch ngModel sin ControlValueAccessor
Aviso de subformulario 🆕 v22.1 NG01354 Detecta la falta de viewProviders