Lección 1 de 314 min
Angular Reactive Forms
FormControl, FormGroup y FormArray, validadores síncronos y asíncronos, formularios tipados y estados del formulario.
Un formulario de registro parece sencillo: email, contraseña y repetir la contraseña. Pero detrás hay muchas preguntas. ¿El email tiene un formato válido? ¿La contraseña es lo bastante larga? ¿Coinciden las dos? ¿Muestro el error mientras el usuario escribe o cuando sale del campo? ¿Hay cambios sin guardar si intenta irse?
Angular Reactive Forms responde a todo eso con un modelo del formulario que vive en la clase del componente: un objeto que sabe en todo momento el valor de cada campo, si es válido y qué ha hecho el usuario con él. En esta lección construirás ese modelo pieza a pieza.
Las piezas de un formulario reactivo
Angular ofrece dos enfoques clásicos: los formularios template-driven, definidos en la plantilla con ngModel, y los Reactive Forms, en los que el modelo es un objeto de la clase. Los reactivos son la opción habitual en formularios con cierta complejidad: el estado es explícito, se prueba sin plantilla y se tipa bien. Para usarlos, añade ReactiveFormsModule a los imports del componente.
FormControl
Un campo: su valor, sus validadores y su estado.
Un FormControl representa un solo campo. Guarda su valor, los validadores que debe cumplir y su estado: si es válido, si el usuario lo ha tocado o modificado. Lo enlazas a un <input> con [formControl] y, desde ese momento, el campo y el objeto están sincronizados en los dos sentidos.
Puedes leer control.value cuando quieras, cambiarlo con setValue() o reaccionar a cada cambio con el Observable valueChanges.
Úsalo cuando
Para un campo suelto que no forma parte de un formulario mayor, como un buscador o un selector de orden.
Ten en cuenta
setValue() desde el código no marca el control como dirty: para Angular, solo el usuario «ensucia» un campo. Si lo necesitas, llama a markAsDirty().
@Component({
selector: "app-buscador",
imports: [ReactiveFormsModule],
template: `<input [formControl]="termino" placeholder="Buscar…">`,
})
export class Buscador {
termino = new FormControl("", { nonNullable: true });
constructor() {
this.termino.valueChanges
.pipe(debounceTime(300), takeUntilDestroyed())
.subscribe((texto) => console.log("Buscar:", texto));
}
}FormGroup y FormBuilder
Agrupa controles en un objeto; FormBuilder lo escribe con menos ruido.
Un FormGroup reúne varios controles con nombre. Su valor es un objeto ({ email: '…', password: '…' }) y su estado resume el de sus hijos: el grupo es inválido si lo es cualquiera de ellos. En la plantilla lo enlazas con [formGroup] en el <form> y cada campo con formControlName.
FormBuilder es un servicio que crea grupos, controles y arrays con una sintaxis compacta: ['', Validators.required] es un control con su valor inicial y sus validadores. Con NonNullableFormBuilder (o fb.nonNullable), los controles recuerdan su valor inicial y reset() vuelve a él en lugar de poner null.
Úsalo cuando
Para cualquier formulario con más de un campo. Puedes anidar grupos para reflejar la forma de los datos, como la dirección dentro de un pedido.
Ten en cuenta
form.value excluye los controles desactivados (disabled). Para enviar todos los datos, usa form.getRawValue().
// pedido.ts
export class Pedido {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
nombre: ["", Validators.required],
direccion: this.fb.group({
calle: [""],
ciudad: ["", Validators.required],
}),
});
}
<!-- pedido.html -->
<form [formGroup]="form" (ngSubmit)="enviar()">
<input formControlName="nombre">
<fieldset formGroupName="direccion">
<input formControlName="ciudad">
</fieldset>
</form>FormArray
Una lista de controles cuya longitud cambia mientras el usuario rellena el formulario.
Un FormArray es como un FormGroup, pero sus controles están en una lista y se accede a ellos por posición. Su valor es un array. Es la pieza para los campos que el usuario puede añadir o quitar: varios teléfonos, las líneas de una factura, los ingredientes de una receta.
Añades controles con push(), los quitas con removeAt(i) y en la plantilla recorres array.controls con @for, enlazando cada uno por su índice dentro de un elemento con formArrayName.
Úsalo cuando
Cuando el número de campos depende del usuario o de los datos que llegan del servidor.
Ten en cuenta
Si cada elemento tiene varios campos (nombre y cantidad de cada ingrediente), mete un FormGroup por elemento. Y en el @for usa track con el propio control, no con $index, para que borrar una fila no confunda a Angular.
// contacto.ts
form = this.fb.group({
telefonos: this.fb.array([this.fb.control("")]),
});
telefonos = this.form.controls.telefonos;
anadir() {
this.telefonos.push(this.fb.control(""));
}
<!-- contacto.html -->
<div formArrayName="telefonos">
@for (control of telefonos.controls; track control; let i = $index) {
<input [formControlName]="i">
<button type="button" (click)="telefonos.removeAt(i)">Quitar</button>
}
</div>
<button type="button" (click)="anadir()">Añadir teléfono</button>Validación
Un validador es una función que recibe un control y devuelve null si el valor es correcto, o un objeto con los errores si no lo es, por ejemplo { required: true }. Angular junta los errores de todos los validadores de un control en su propiedad errors.
Validadores integrados
Las reglas más comunes (obligatorio, email, longitud, patrón), ya escritas.
La clase Validators trae los casos más frecuentes: required, requiredTrue (para casillas que hay que marcar), email, min y max para números, minLength y maxLength para textos y pattern para una expresión regular. Se pasan al crear el control, solos o en un array.
Cada uno añade su clave al objeto de errores cuando falla: { required: true }, { email: true } o { minlength: { requiredLength: 8, actualLength: 3 } }, con la clave en minúsculas. Esa información te sirve para escribir mensajes precisos.
Úsalo cuando
Siempre que una regla común encaje. Son rápidos, están probados y mantienen coherentes los errores de toda la app.
Ten en cuenta
Salvo required y requiredTrue, dan por bueno un campo vacío: minLength(8) no se queja de ''. Combínalos con required si el campo es obligatorio. Y Validators.email acepta direcciones como «ana@local», sin punto en el dominio.
form = this.fb.group({
nombre: ["", [Validators.required, Validators.maxLength(50)]],
email: ["", [Validators.required, Validators.email]],
edad: [18, [Validators.min(18), Validators.max(120)]],
codigo: ["", Validators.pattern(/^\d{5}$/)],
acepto: [false, Validators.requiredTrue],
});Validadores propios y entre campos
Funciones tuyas para reglas de negocio, incluidas las que comparan varios campos.
Un validador propio es una función de tipo ValidatorFn: recibe un AbstractControl y devuelve null o un objeto de errores. Si necesita parámetros, escribe una función que devuelva el validador, igual que hace Validators.minLength(8).
Las reglas que comparan varios campos, como «las contraseñas coinciden» o «la vuelta es posterior a la ida», se ponen en el grupo, con la opción validators. El validador recibe el grupo entero, así que puede leer todos sus controles, y el error queda en form.errors, no en el de un campo concreto.
Úsalo cuando
Para reglas de tu dominio que ningún validador integrado cubre: un número de documento con dígito de control, una lista de palabras prohibidas, un rango de fechas.
Ten en cuenta
Como el error de un validador de grupo vive en el grupo, tienes que mostrarlo tú donde tenga sentido, normalmente junto al segundo campo: @if (form.hasError('noCoinciden')).
// Validador con parámetro
export function sinPalabras(prohibidas: string[]): ValidatorFn {
return (control) =>
prohibidas.some((p) => String(control.value).includes(p))
? { palabraProhibida: true }
: null;
}
// Validador de grupo: compara dos campos
export const coinciden: ValidatorFn = (grupo) => {
const { password, confirmar } = grupo.value;
return password === confirmar ? null : { noCoinciden: true };
};
form = this.fb.group(
{ password: [""], confirmar: [""] },
{ validators: coinciden },
);Validadores asíncronos
Reglas que necesitan esperar una respuesta, normalmente del servidor.
Algunas comprobaciones no se pueden hacer en el navegador: ¿está libre este nombre de usuario?, ¿existe este código de descuento? Un validador asíncrono (AsyncValidatorFn) devuelve una Promise o un Observable que entrega null o los errores. Se pasa en la opción asyncValidators.
Angular solo lo ejecuta cuando los validadores síncronos han pasado, para no consultar al servidor con un valor que ya es incorrecto. Mientras espera la respuesta, el control y su grupo quedan en estado pending.
Úsalo cuando
Para comprobaciones que dependen de datos del servidor y que quieres mostrar antes de enviar el formulario.
Ten en cuenta
Por defecto se ejecuta con cada cambio del valor. Con updateOn: 'blur' el control valida al salir del campo. Y el servidor debe volver a comprobarlo todo al recibir el formulario: la validación del cliente ayuda, pero no garantiza nada.
export function usuarioLibre(api: UsuariosApi): AsyncValidatorFn {
return (control) =>
api.existe(control.value).pipe(
map((existe) => (existe ? { usuarioOcupado: true } : null)),
);
}
usuario = this.fb.control("", {
validators: [Validators.required, Validators.minLength(3)],
asyncValidators: [usuarioLibre(inject(UsuariosApi))],
updateOn: "blur",
});Atención
Todo lo que valida el navegador lo puede saltar cualquiera con las herramientas para desarrolladores o enviando la petición a mano. Valida en el cliente para ayudar al usuario y en el servidor para proteger tus datos. Lo verás en la siguiente lección.
Estado del formulario y errores
Estados de un control
Válido o no, modificado o no, tocado o no: tres preguntas que responde cada control.
Cada control, grupo y array responde a tres preguntas. ¿Cumple las reglas? valid o invalid, más pending mientras corre un validador asíncrono, todo resumido en status. ¿El usuario ha cambiado el valor? pristine (intacto) o dirty (modificado). ¿El usuario ha pasado por el campo? untouched o touched, que se activa cuando el campo pierde el foco.
Los estados se propagan hacia arriba: si un control es inválido, su grupo también; si un control está dirty, el formulario entero lo está. Puedes forzarlos con markAsTouched(), markAllAsTouched(), markAsPristine() o reset().
Úsalo cuando
dirty, para avisar de cambios sin guardar al salir de la página; invalid, para no enviar un formulario incorrecto; pending, para mostrar un indicador mientras se valida.
Ten en cuenta
dirty no vuelve a false aunque el usuario deje el valor como estaba. Si necesitas saber si los datos cambiaron de verdad, compara el valor actual con el original.
<p>Estado: {{ form.status }}</p> <!-- VALID, INVALID o PENDING -->
@if (form.dirty) {
<p>Tienes cambios sin guardar.</p>
}
<button type="submit" [disabled]="form.pending">Crear cuenta</button>Mostrar los errores
Enseñar cada error en el momento justo: ni antes de que el usuario escriba ni tarde.
Que un control sea inválido no significa que debas decirlo ya: un formulario recién abierto y lleno de mensajes en rojo espanta. El patrón habitual es mostrar el error cuando el control es inválido y el usuario ya lo ha tocado: control.invalid && control.touched.
Para el texto, consulta el error concreto con hasError('required') o lee errors para sacar detalles, como la longitud mínima. Al enviar, llama a form.markAllAsTouched(): así aparecen de golpe los errores de los campos que el usuario se saltó.
Úsalo cuando
En todos los formularios. Si repites el mismo bloque en muchos campos, extráelo a un componente que reciba el control y elija el mensaje.
Ten en cuenta
Asocia el mensaje al campo con aria-describedby y marca el campo con aria-invalid para que un lector de pantalla lo anuncie. Un texto en rojo no basta.
<!-- email es this.form.controls.email -->
<input id="email" type="email" formControlName="email"
[attr.aria-invalid]="email.invalid && email.touched"
aria-describedby="email-error">
@if (email.invalid && email.touched) {
<p id="email-error">
@if (email.hasError("required")) { El email es obligatorio. }
@else { Revisa el formato del email. }
</p>
}Interactivo
Inspector de un formulario reactivo
Escribe y sal de cada campo. Debajo ves el estado que Angular guarda de cada control y del grupo, como si consultaras form.controls.email.dirty.
controls.email
- value:
- ""
- valid:
- false
- errors:
- {"required":true}
- dirty:
- false
- touched:
- false
controls.password
- value:
- ""
- valid:
- false
- errors:
- {"required":true}
- dirty:
- false
- touched:
- false
controls.confirmar
- value:
- ""
- valid:
- false
- errors:
- {"required":true}
- dirty:
- false
- touched:
- false
form (FormGroup)
- value:
- {"email":"","password":"","confirmar":""}
- valid:
- false
- errors:
- null
- dirty:
- false
- touched:
- false
pristine y untouched son siempre lo contrario de dirty y touched. En amarillo, lo que cambió en tu última acción.
Todo está pristine y untouched. Escribe en el email para ver qué cambia, y luego pulsa Tab.
El mismo formulario en Angular
const coinciden: ValidatorFn = (grupo) => {
const { password, confirmar } = grupo.value;
return password === confirmar ? null : { noCoinciden: true };
};
export class Registro {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group(
{
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmar: ['', Validators.required],
},
{ validators: coinciden },
);
}
<!-- registro.html -->
<form [formGroup]="form" (ngSubmit)="enviar()">
<input type="email" formControlName="email">
@if (form.controls.email.invalid && form.controls.email.touched) {
<p>Revisa el email</p>
}
</form>Formularios tipados y Signal Forms
Formularios tipados
TypeScript conoce la forma de cada formulario y te avisa si te equivocas de campo o de tipo.
Desde la v14, los Reactive Forms están tipados: this.fb.group({ email: [''], edad: [18] }) produce un grupo en el que form.controls.email existe, form.value.edad es un número y form.controls.emial es un error de compilación. TypeScript deduce los tipos de los valores iniciales.
Un matiz: por defecto, un FormControl creado con '' es de tipo string | null, porque reset() lo deja en null. Con nonNullable (o NonNullableFormBuilder) el tipo es string y reset() vuelve al valor inicial. Además, form.value es parcial, porque excluye los controles desactivados; getRawValue() devuelve el objeto completo.
Úsalo cuando
Siempre: no tienes que activar nada. Usa NonNullableFormBuilder y te ahorrarás null inesperados.
Ten en cuenta
UntypedFormGroup y UntypedFormControl existen para migrar código antiguo; si los ves, ese formulario ha perdido el tipado. Y los tipos solo protegen al compilar: el servidor debe validar lo que recibe.
const fb = inject(FormBuilder);
const a = fb.control(""); // FormControl<string | null>
const b = fb.nonNullable.control(""); // FormControl<string>
const form = fb.nonNullable.group({ email: [""], edad: [18] });
form.controls.email.setValue(42); // Error: number no es string
const datos = form.getRawValue(); // { email: string; edad: number }Signal Forms
La nueva API de formularios de Angular, construida sobre señales.
- Paquete:
- @angular/forms/signals
Signal Forms parte de una señal con el modelo de datos y de la función form(), que crea un árbol de campos con su valor, sus errores y sus estados como señales. Las reglas se declaran en un esquema con funciones como required() o email(), y en la plantilla cada campo se enlaza con la directiva [formField]. Vive en @angular/forms/signals.
Llegó en la v21 como experimental y, según la documentación oficial, es estable desde la v22. Reactive Forms sigue plenamente soportado y es lo que encontrarás en la mayoría de los proyectos, así que conviene dominar los dos.
Úsalo cuando
Para formularios nuevos en aplicaciones basadas en señales, sobre todo si el equipo ya trabaja con ellas. Si tienes formularios reactivos que funcionan, no hay prisa por migrar.
Ten en cuenta
Es una API reciente: algunos nombres han cambiado entre versiones y muchos tutoriales usan los antiguos. Comprueba los ejemplos en la guía oficial.
import { form, FormField, required, email } from "@angular/forms/signals";
@Component({
selector: "app-acceso",
imports: [FormField],
template: `
<input type="email" [formField]="acceso.email">
@if (acceso.email().touched() && acceso.email().invalid()) {
<p>Revisa el email</p>
}
`,
})
export class Acceso {
modelo = signal({ email: "", password: "" });
acceso = form(this.modelo, (ruta) => {
required(ruta.email);
email(ruta.email);
});
}Interactivo
Tres formas de hacer formularios en Angular
Activa o desactiva opciones para compararlas cara a cara.
| Característica | Template-driven | Reactive Forms | Signal Forms |
|---|---|---|---|
| Dónde vive el modelo | En la plantilla, con ngModel | En la clase: FormGroup, FormControl, FormArray | Una señal con los datos y la función form() |
| Cómo lees el estado | Directivas y referencias en la plantilla | Propiedades y Observables como valueChanges | Señales: campo().invalid() |
| Tipado | Limitado | Tipado desde la v14 | Deducido del modelo |
| Validación | Atributos como required y directivas | Funciones ValidatorFn y AsyncValidatorFn | Esquema con funciones como required() |
| Situación actual | Estable; para formularios sencillos | Estable; la opción más extendida | Estable desde la v22; la más reciente |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Necesitas un único campo de búsqueda que lance la consulta 300 ms después de que el usuario deje de escribir.
Para recordar
- En Reactive Forms el formulario es un objeto de la clase:
FormControlpara un campo,FormGrouppara varios con nombre yFormArraypara listas. - Un validador devuelve
nullo un objeto de errores; las reglas entre campos van en el grupo y las que consultan al servidor son asíncronas. - Cada control sabe si es válido, si el usuario lo ha modificado (
dirty) y si ha salido de él (touched); muestra los errores coninvalid && touched. - Usa
NonNullableFormBuilderpara tener tipos sinnully unreset()que vuelve al valor inicial. - Signal Forms es la API más reciente y estable desde la v22, pero Reactive Forms sigue siendo lo más extendido.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.¿Cuándo pasa un control de
pristineadirty?2.¿Qué hace que un control pase a
touched?3.Un control tiene
[Validators.required, Validators.minLength(8)]y su valor es''. ¿Qué contieneerrors?4.¿Cuándo ejecuta Angular el validador asíncrono de un control?
5.Creas
campo = fb.nonNullable.control('hola'), el usuario escribe «adiós» y llamas acampo.reset(). ¿Qué valor tiene?