Módulo 17: Formularios reactivos
Objetivos del módulo
- Entender el modelo de los formularios reactivos: un árbol de controles que vive en la clase y se enlaza con la plantilla
- Crear y manejar
FormControl,FormGroup,FormArrayyFormRecord - Reducir el código repetido con
FormBuilderyNonNullableFormBuilder - Aprovechar el tipado: distinguir
valuedegetRawValue()y saber cuándo aparecenulloundefined - Enseñar errores en el momento adecuado usando los estados
touched,dirty,pendingydisabled - Conectar los formularios con las señales, y evitar que la vista se quede desfasada en una aplicación zoneless
- Escribir validadores propios, de grupo, condicionales y asíncronos contra la API EMPRESA
- Construir formularios dinámicos a partir de una configuración
- Crear controles propios con
ControlValueAccessor, y con señales desde Angular 22 - Volcar en el formulario los errores que devuelve el servidor
- Diagnosticar los errores más habituales
1. Qué son los formularios reactivos
En el módulo 16 el formulario se describía en la plantilla: pones ngModel en cada <input> y Angular crea los objetos que controlan cada campo por detrás. Los formularios reactivos dan la vuelta a esa idea. Tú creas el modelo del formulario en la clase, como un objeto normal de TypeScript, y la plantilla solo se enlaza con él.
Ese cambio parece pequeño, pero tiene consecuencias:
- Lo que hay en la clase es la verdad. Puedes leer el estado del formulario, cambiar un valor o añadir un campo sin tocar el DOM.
- La estructura es explícita y está tipada. TypeScript sabe que
formulario.controls.salario.valuees unnumber | null. - Las pruebas son sencillas. Un validador es una función, y un formulario se prueba sin dibujar nada.
- Lo dinámico es natural. Una lista de líneas de pedido que crece y decrece es un array en tu código, no un truco de plantilla.
El nombre viene de que cada control expone sus cambios como observables (valueChanges, statusChanges, events), los que estudiaste en el módulo 14. No hace falta pensar en RxJS para rellenar un formulario sencillo, pero sí para reaccionar a lo que escribe el usuario.
Un árbol de controles
Todo formulario reactivo es un árbol. Cada nodo hereda de AbstractControl, así que todos comparten value, status, errors, touched, dirty y compañía:
| Clase | Qué representa | Su valor |
|---|---|---|
FormControl<T> | Un campo: una caja de texto, un select, una casilla | Un valor de tipo T |
FormGroup<{...}> | Un conjunto de controles con nombres fijos | Un objeto |
FormArray<C> | Una lista ordenada de controles iguales | Un array |
FormRecord<C> | Un grupo cuyas claves se conocen en ejecución | Un objeto con claves libres |
Figura 1. El formulario de un pedido como árbol. El estado sube: si una cantidad es inválida, también lo son su línea, el array y el formulario entero.
La regla más importante del árbol es que el estado sube. Si un control hoja se vuelve inválido, sus padres se vuelven inválidos. Si cambia su valor, cambia el valor de todos sus antepasados. Por eso basta con mirar formulario.valid en la raíz para saber si todo está bien.
Quién habla con quién
Un FormControl no conoce el DOM y un <input> no conoce los FormControl. Entre los dos hay una directiva, formControlName o [formControl], y un adaptador, el ControlValueAccessor. Angular trae adaptadores para los elementos nativos: el de texto escribe en value y escucha input, el de casillas usa checked y change, el de números convierte el texto en number…
Figura 2. El adaptador traduce en los dos sentidos. Cuando escribas tu propio control, en la sección 12, tú harás de adaptador.
📘 Concepto: En un formulario reactivo el flujo es síncrono y sin intermediarios. Cuando el evento
inputllega al adaptador, elFormControlya tiene el valor nuevo antes de que termine ese mismo evento. En uno de plantilla, en cambio, la creación y el registro de los controles dependen del ciclo de renderizado, y por eso hay que esperar un turno para leerlos.
2. FormControl: un campo suelto
Para usar las directivas hay que importar ReactiveFormsModule en el componente. Este es el formulario más pequeño posible, un solo control enlazado con [formControl]:
// src/app/formularios/control-simple.ts
import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-control-simple',
imports: [ReactiveFormsModule],
template: `
<label>Nombre <input [formControl]="nombre" /></label>
<p>Valor en el modelo: <strong>{{ valor() }}</strong></p>
<button type="button" (click)="poner()">Poner "Luis"</button>
<button type="button" (click)="nombre.reset()">Restablecer</button>
`,
})
export class ControlSimple {
protected readonly nombre = new FormControl('Ana', { nonNullable: true });
// valueChanges no emite el valor inicial: por eso initialValue
protected readonly valor = toSignal(this.nombre.valueChanges, {
initialValue: this.nombre.value,
});
protected poner() {
this.nombre.setValue('Luis');
}
}

Figura 3. Lo que escribes pasa al modelo al instante, y valor() lo muestra.
Fíjate en tres cosas. La primera, formControl se enlaza con corchetes porque le pasas un objeto. La segunda, el toSignal del módulo 15 lleva initialValue porque valueChanges solo emite a partir del primer cambio. La tercera, nombre se usa directamente desde la plantilla: es protected, como todo lo que lee la plantilla.
Leer y escribir
Un control es un objeto corriente. Puedes crearlo y usarlo sin plantilla, algo muy cómodo para experimentar:
const salario = new FormControl<number | null>(1500, [Validators.required, Validators.min(1000)]);
console.log(salario.value, salario.status, salario.valid);
salario.setValue(800);
console.log(salario.value, salario.status, JSON.stringify(salario.errors));
salario.setValue(null);
console.log(salario.value, salario.status, JSON.stringify(salario.errors));
console.log('pristine', salario.pristine, 'dirty', salario.dirty);
1500 VALID true
800 INVALID {"min":{"min":1000,"actual":800}}
null INVALID {"required":true}
pristine true dirty false
Observa la última línea: tras dos setValue el control sigue pristine (intacto). dirty y touched solo cambian por acciones del usuario, no por código. Esto es deliberado: si cargas un empleado de la API con setValue, el formulario no debe parecer modificado.
| Miembro | Qué hace |
|---|---|
value | Valor actual |
setValue(v) | Sustituye el valor |
patchValue(v) | En un FormControl es igual que setValue; la diferencia aparece en los grupos |
reset(v?) | Vuelve al valor por defecto (o a v), y lo deja pristine y untouched |
disable() / enable() | Desactiva o activa el control y su elemento |
valueChanges | Observable que emite cada valor nuevo |
statusChanges | Observable que emite cada estado nuevo |
events | Observable que emite todos los cambios (sección 7) |
Por qué nonNullable
Sin opciones, new FormControl('Ana') tiene el tipo FormControl<string | null>. La razón es histórica: reset() sin argumentos dejaba el control a null. Con nonNullable: true, el valor por defecto pasa a ser el inicial, y el tipo se queda en string:
const conNull = new FormControl('Ana');
const sinNull = new FormControl('Ana', { nonNullable: true });
conNull.setValue('Luis');
sinNull.setValue('Luis');
conNull.reset();
sinNull.reset();
console.log(conNull.value, sinNull.value, sinNull.defaultValue);
null Ana Ana
💡 Consejo: Usa
nonNullable: trueen todos los campos de texto. Dejanullpara lo que de verdad puede no tener valor, como un salario aún sin escribir o unselectsin elegir.
Evitar notificaciones
Los métodos que cambian un control aceptan opciones. Las dos que más se usan son:
{ emitEvent: false }: cambia el valor sin emitir envalueChanges. Sirve para romper bucles, cuando reaccionas a un cambio cambiando otro control que a su vez te avisa.{ onlySelf: true }: actualiza este control pero no recalcula a sus padres.
const campo = new FormControl('a', { nonNullable: true });
const vistos: string[] = [];
campo.valueChanges.subscribe((v) => vistos.push(v));
campo.setValue('b');
campo.setValue('c', { emitEvent: false });
campo.setValue('d');
console.log(vistos.join(), campo.value);
b,d d
3. FormGroup: un formulario completo
Un FormGroup agrupa controles con nombre. Usaremos el alta de un empleado de EMPRESA, con dos niveles: los datos laborales y un subgrupo contacto.
// src/app/formularios/alta-empleado.ts
import { Component, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-alta-empleado',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="formulario" (ngSubmit)="enviar()">
<label>Nombre <input formControlName="nombre" /></label>
<label>Oficio <input formControlName="oficio" /></label>
<label>Salario <input type="number" formControlName="salario" /></label>
<fieldset formGroupName="contacto">
<legend>Contacto</legend>
<label>Email <input type="email" formControlName="email" /></label>
<label>Teléfono <input formControlName="telefono" /></label>
</fieldset>
<button type="submit" [disabled]="formulario.invalid">Guardar</button>
</form>
@if (enviado(); as datos) {
<pre>{{ datos }}</pre>
}
`,
})
export class AltaEmpleado {
protected readonly enviado = signal<string | null>(null);
protected readonly formulario = new FormGroup({
nombre: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
oficio: new FormControl('ANALISTA', { nonNullable: true }),
salario: new FormControl<number | null>(null, [Validators.required, Validators.min(1)]),
contacto: new FormGroup({
email: new FormControl('', { nonNullable: true, validators: [Validators.email] }),
telefono: new FormControl('', { nonNullable: true }),
}),
});
protected enviar() {
this.enviado.set(JSON.stringify(this.formulario.getRawValue(), null, 2));
}
}
Las directivas siguen la forma del árbol:
| Directiva | Dónde va | Enlaza con |
|---|---|---|
[formGroup] | <form> | El grupo raíz, por referencia |
formControlName | Un control | Un hijo del grupo, por nombre |
formGroupName | Un contenedor (fieldset, div…) | Un subgrupo, por nombre |
formArrayName | Un contenedor | Un FormArray, por nombre (sección 10) |
[formControl] | Un control | Un FormControl suelto, por referencia |
Tres detalles que conviene entender:
(ngSubmit)y no(submit). La directiva[formGroup]escucha elsubmitnativo, llama apreventDefault()para que el navegador no recargue la página, marca el formulario como enviado y emitengSubmit.- Los botones que no envían llevan
type="button". Dentro de un<form>, un<button>sin tipo es de envío. Un botón “Añadir línea” sin tipo lanzaríangSubmitcada vez. [disabled]="formulario.invalid"tiene un precio. Es cómodo, pero un botón desactivado no explica nada. Quien usa un lector de pantalla no sabe qué falta. En la sección 6 verás la alternativa recomendada.
⚠️ Importante: Nunca pongas el atributo
disabledni[disabled]en un<input>conformControlName. El estado lo manda el modelo: se crea connew FormControl({ value: '', disabled: true })y se cambia condisable()yenable(). Si lo haces en la plantilla, Angular avisa en la consola, como verás en la sección 14. El[disabled]del<button>anterior es correcto, porque un botón no es un control del formulario.
value frente a getRawValue()
Un control desactivado sigue dentro del árbol, pero sale de value:
const empleado = new FormGroup({
empId: new FormControl({ value: 160, disabled: true }, { nonNullable: true }),
nombre: new FormControl('Marta', { nonNullable: true }),
});
console.log(JSON.stringify(empleado.value));
console.log(JSON.stringify(empleado.getRawValue()));
{"nombre":"Marta"}
{"empId":160,"nombre":"Marta"}
Esto explica un tipo que sorprende: formulario.value es Partial<...>, con todos los campos opcionales, porque cualquiera de ellos podría estar desactivado. getRawValue() incluye siempre todos, y por eso su tipo es completo. Para enviar datos a una API, usa getRawValue().
Hay un caso extremo que confunde mucho: si desactivas todos los hijos, el grupo pasa a DISABLED. Y DISABLED no es VALID:
const grupo = new FormGroup({
a: new FormControl({ value: '', disabled: true }, Validators.required),
});
console.log(grupo.status, 'valid', grupo.valid, 'invalid', grupo.invalid);
console.log(JSON.stringify(grupo.value));
DISABLED valid false invalid false
{"a":""}
Un control desactivado no se valida: por eso el required no cuenta. Y fíjate en que el value del grupo incluye el control, pese a lo dicho antes. Cuando todos los hijos están desactivados, value los devuelve todos. Razón de más para usar getRawValue() y olvidarse.
setValue frente a patchValue
En un grupo, la diferencia es real. setValue exige la forma exacta y lanza un error si falta o sobra algo. patchValue toma lo que reconoce e ignora el resto:
const g = new FormGroup({
nombre: new FormControl('', { nonNullable: true }),
oficio: new FormControl('', { nonNullable: true }),
});
g.patchValue({ nombre: 'Ana' });
console.log(JSON.stringify(g.value));
try {
(g as FormGroup).setValue({ nombre: 'Luis' });
} catch (e) {
console.log((e as Error).message);
}
try {
(g as FormGroup).setValue({ nombre: 'Luis', oficio: 'X', edad: 40 });
} catch (e) {
console.log((e as Error).message);
}
{"nombre":"Ana","oficio":""}
NG01002: Must supply a value for form control with name: 'oficio'. Find more at https://v22.angular.dev/errors/NG01002
NG01001: Cannot find form control with name: 'edad'
Usa setValue cuando tengas el objeto completo y quieras enterarte si la forma cambia. Usa patchValue para rellenar un formulario con parte de los datos, por ejemplo con una respuesta de la API que trae más campos de los que muestras.
reset y el valor por defecto
reset() hace algo más que vaciar: deja el formulario pristine y untouched. Si le pasas un objeto, ese es el nuevo valor actual, aunque no cambia el valor por defecto. Por eso, tras guardar con éxito, lo habitual es formulario.reset(datosGuardados): el formulario se queda con los datos y deja de considerarse modificado.
También puedes pasar el estado de un control: reset({ nombre: { value: 'Ana', disabled: true } }).
4. FormBuilder: menos ruido
Escribir new FormControl(...) en cada campo cansa. FormBuilder es un servicio con métodos group, control, array y record que hacen lo mismo con menos texto. Y su variante NonNullableFormBuilder aplica nonNullable: true a todo lo que crea:
// src/app/formularios/formulario-empleado.ts
import { inject, Injectable } from '@angular/core';
import { NonNullableFormBuilder, Validators } from '@angular/forms';
@Injectable({ providedIn: 'root' })
export class FormularioEmpleado {
private readonly fb = inject(NonNullableFormBuilder);
crear() {
return this.fb.group({
nombre: ['', [Validators.required, Validators.maxLength(40)]],
apellidos: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
telefono: ['', Validators.pattern(/^\d{9}$/)],
oficio: ['', Validators.required],
salario: this.fb.control<number | null>(null, [Validators.required, Validators.min(1)]),
comision: this.fb.control<number | null>(null, Validators.min(0)),
deptId: this.fb.control<number | null>(null),
});
}
}
export type GrupoEmpleado = ReturnType<FormularioEmpleado['crear']>;
Cada entrada admite varias formas:
| Forma | Ejemplo |
|---|---|
| Valor | nombre: '' |
Array [valor, validadores, asíncronos] | email: ['', Validators.email, emailLibre()] |
| Control ya creado | salario: this.fb.control<number \| null>(null) |
| Subgrupo | contacto: this.fb.group({...}) |
Fíjate en que salario se declara con this.fb.control<number | null>(null, ...). Si escribieras salario: [null, ...], TypeScript solo vería null y el tipo sería inútil.
He separado la creación del formulario en un servicio a propósito. Así lo pueden usar el alta y la edición sin repetirlo, que es justo lo que hará la ficha de la sección 13. Fuera de la clase del servicio, GrupoEmpleado nos da su tipo sin escribirlo a mano. En un formulario que solo se use una vez, créalo en el propio componente, como formulario = this.fb.group({...}).
Si necesitas mezclar, FormBuilder normal tiene la propiedad nonNullable: inject(FormBuilder).nonNullable.group({...}).
💡 Consejo: En los formularios de este curso se usa
inject(NonNullableFormBuilder). Se ahorra el{ nonNullable: true }de cada campo y deja los tipos limpios. Losnullque quedan son los que has pedido tú.
5. Tipado: qué sabe TypeScript de tu formulario
Desde Angular 14, los formularios son genéricos. TypeScript deduce el tipo de todo el árbol a partir de lo que escribes:
const fb = new FormBuilder().nonNullable;
const f = fb.group({
nombre: 'Ana',
salario: fb.control<number | null>(2000),
contacto: fb.group({ email: 'ana@empresa.es' }),
});
const salario: number | null = f.controls.salario.value;
const email: string = f.controls.contacto.controls.email.value;
const raw: { nombre: string; salario: number | null; contacto: { email: string } } = f.getRawValue();
const parcial: string | undefined = f.value.nombre;
// @ts-expect-error: Argument of type 'number' is not assignable to parameter of type 'string'
f.controls.nombre.setValue(42);
// @ts-expect-error: Property 'apellidos' does not exist on type ...
f.controls.apellidos;
console.log(salario, email, raw.nombre, parcial);
2000 ana@empresa.es Ana Ana
Las líneas con @ts-expect-error son errores de compilación reales: el comentario le dice a TypeScript que los espere. Sin él, ng build se detendría ahí, que es lo que quieres. Un campo mal escrito o un valor del tipo equivocado ya no llegan a ejecución.
Dos formas de acceder a un control, y por qué una es mejor:
formulario.controls.salario; // tipado: FormControl<number | null>
formulario.get('salario'); // AbstractControl | null: hay que comprobar el null
get admite rutas (get('contacto.email')) y es útil cuando la clave llega en una variable, como en los errores del servidor de la sección 13. Para todo lo demás, usa controls.
Tipar a mano
Con FormBuilder rara vez hace falta. Cuando quieras declarar el tipo antes de crear el control, por ejemplo en un FormArray vacío o en una función que recibe un grupo, usa una interfaz con los controles:
interface GrupoLinea {
productoId: FormControl<number | null>;
cantidad: FormControl<number>;
}
const lineas = new FormArray<FormGroup<GrupoLinea>>([]);
Controles opcionales
Si un control puede no existir, márcalo como opcional con ?. Solo los opcionales se pueden quitar con removeControl, y TypeScript te obliga a comprobar su presencia:
interface GrupoCliente {
nombre: FormControl<string>;
nif?: FormControl<string>;
}
const cliente = new FormGroup<GrupoCliente>({
nombre: new FormControl('Academia Nova', { nonNullable: true }),
});
cliente.addControl('nif', new FormControl('B12345678', { nonNullable: true }));
console.log(JSON.stringify(cliente.value));
cliente.removeControl('nif');
console.log(JSON.stringify(cliente.value), cliente.contains('nif'));
{"nombre":"Academia Nova","nif":"B12345678"}
{"nombre":"Academia Nova"} false
FormRecord: claves desconocidas
Cuando las claves llegan en ejecución y todos los valores son del mismo tipo, usa FormRecord. Por ejemplo, una casilla por cada departamento:
const avisos = new FormRecord<FormControl<boolean>>({});
for (const dept of ['VENTAS', 'MARKETING']) {
avisos.addControl(dept, new FormControl(false, { nonNullable: true }));
}
avisos.controls['VENTAS']?.setValue(true);
console.log(JSON.stringify(avisos.getRawValue()));
{"VENTAS":true,"MARKETING":false}
Con FormRecord TypeScript no conoce las claves, así que usa controls['VENTAS'], que puede ser undefined. Es el precio de lo dinámico.
El código antiguo: UntypedFormGroup
Si ves UntypedFormGroup, UntypedFormControl o UntypedFormBuilder, son alias con any, creados para migrar proyectos anteriores a Angular 14 sin romperlos. Funcionan, pero no los uses en código nuevo.
6. Estados y mensajes de error
Cada control mantiene varios indicadores independientes:
| Propiedad | Cierta cuando | Contraria | Clase CSS |
|---|---|---|---|
valid | Pasa todos los validadores | invalid | ng-valid / ng-invalid |
pending | Hay un validador asíncrono en marcha | ng-pending | |
disabled | Está desactivado | enabled | |
dirty | El usuario cambió el valor | pristine | ng-dirty / ng-pristine |
touched | El usuario salió del campo | untouched | ng-touched / ng-untouched |
submitted (en la directiva, no en el control) | Se envió el formulario | ng-submitted |
status resume los cuatro primeros en un único valor: 'VALID', 'INVALID', 'PENDING' o 'DISABLED'.
Las clases las pone Angular en el elemento, como viste en el módulo 16, así que puedes decorar los campos solo con CSS. Estos estilos se usan en todo el módulo:
/* src/styles.css */
body { font-family: 'Segoe UI', Arial, sans-serif; color: #263238; margin: 1.25rem; }
form, fieldset { display: grid; gap: .7rem; max-width: 34rem; }
fieldset { border: 1px solid #c5cae9; border-radius: 6px; }
label { display: grid; gap: .2rem; font-weight: 600; font-size: .92rem; }
input, select, textarea { font: inherit; padding: .4rem .5rem; border: 1px solid #90a4ae; border-radius: 4px; }
input[type='checkbox'] { justify-self: start; }
input.ng-invalid.ng-touched, select.ng-invalid.ng-touched { border-color: #d32f2f; background: #fff5f5; }
input.ng-pending { border-color: #ff7043; }
button { font: inherit; padding: .4rem .8rem; border: 1px solid #3949ab; border-radius: 4px; background: #3949ab; color: #fff; cursor: pointer; justify-self: start; }
button[type='button'] { background: #fff; color: #3949ab; }
button:disabled { opacity: .5; cursor: not-allowed; }
.error { color: #c62828; font-size: .85rem; font-weight: 400; margin: 0; }
.aviso { color: #e65100; font-size: .85rem; margin: 0; }
.ok { color: #2e7d32; margin: 0; }
.fila { display: flex; gap: .5rem; align-items: center; }
.fila > select { flex: 1; }
.fila > input { width: 6rem; }
pre { background: #eceff1; padding: .6rem; border-radius: 4px; font-size: .85rem; }
Cuándo enseñar un error
Si muestras los errores nada más cargar, el usuario se encuentra una pantalla en rojo antes de escribir. Si los muestras solo al enviar, escribe todo a ciegas. La regla habitual es enseñar el error cuando el usuario ha salido del campo, es decir, con touched, y forzarlo al enviar con markAllAsTouched():
protected enviar() {
if (this.formulario.invalid) {
this.formulario.markAllAsTouched(); // enseña todos los errores que faltaban
return;
}
// enviar...
}
Así el botón “Guardar” se queda activo. Al pulsarlo con datos incompletos, el usuario ve qué le falta, en vez de un botón gris que no responde. Es mejor para todos, y en especial para quien usa teclado o lector de pantalla.
Leer un error
errors es un objeto cuyas claves son los errores activos, o null si no hay ninguno. Cada validador aporta sus datos:
| Validador | Clave | Datos del error |
|---|---|---|
Validators.required | required | true |
Validators.requiredTrue | required | true |
Validators.email | email | true |
Validators.min(n) | min | { min, actual } |
Validators.max(n) | max | { max, actual } |
Validators.minLength(n) | minlength | { requiredLength, actualLength } |
Validators.maxLength(n) | maxlength | { requiredLength, actualLength } |
Validators.pattern(r) | pattern | { requiredPattern, actualValue } |
Fíjate en el detalle de las mayúsculas: los métodos son minLength y maxLength, y las claves, minlength y maxlength. Es un error clásico que deja el mensaje sin aparecer.
hasError('required') y getError('min') leen errors de forma cómoda, y además aceptan una ruta: formulario.hasError('required', 'contacto.email').
⚠️ Importante:
Validators.emailyValidators.patterndan por bueno el campo vacío, yminLengthtambién. Un campo opcional con formato solo se valida si tiene algo. Si además es obligatorio, añadeValidators.required.
Un componente para los mensajes (primer intento)
Repetir @if por cada error y cada campo no es viable. La idea natural es un componente que recibe el control:
// Cuidado: esta versión tiene un fallo, lo explica la sección 7
@Component({
selector: 'app-error-campo',
template: `
@if (control().touched && control().errors; as errores) {
<p class="error">{{ errores | json }}</p>
}
`,
imports: [JsonPipe],
})
export class ErrorCampoRoto {
readonly control = input.required<AbstractControl>();
}
Parece perfecto, y en Angular 22 no funciona. Antes de seguir tienes que entender cómo se relacionan los formularios y la detección de cambios.
7. Formularios, señales y detección de cambios
Los formularios reactivos nacieron antes que las señales. FormControl guarda su estado en propiedades normales: value no es una señal, ni tampoco touched o status. Como sabes por el módulo 09, en una aplicación zoneless Angular solo redibuja un componente cuando algo le avisa: una señal que la plantilla lee, un evento de esa plantilla, o una llamada a markForCheck.
Cuando escribes en un <input>, el evento lo escucha una directiva de esa plantilla, y el componente se marca para revisar. Por eso el formulario de la sección 3 funciona. Pero si un control cambia sin un evento en la plantilla, nadie avisa, y la vista se queda desfasada.
La trampa: cambios que llegan de fuera
Estos dos componentes hacen lo mismo. Al pulsar el botón, un temporizador (imagina que es la respuesta de una API) cambia el nombre. El primero lee el control directamente. El segundo, a través de una señal:
// src/app/formularios/trampa-vista.ts
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { estadoDe } from './estado-de';
const crearFormulario = () =>
new FormGroup({ nombre: new FormControl('Ana', { nonNullable: true }) });
@Component({
selector: 'app-vista-cruda',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="formulario">
<h3>Leyendo el control</h3>
<input formControlName="nombre" />
<p id="crudo">En la plantilla: {{ formulario.getRawValue().nombre }}</p>
<button type="button" (click)="cargar()">Cargar de la API</button>
</form>
`,
})
export class VistaCruda {
protected readonly formulario = crearFormulario();
protected cargar() {
setTimeout(() => this.formulario.patchValue({ nombre: 'Marta' }), 100);
}
}
@Component({
selector: 'app-vista-senal',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="formulario">
<h3>A través de una señal</h3>
<input formControlName="nombre" />
<p id="senal">En la plantilla: {{ estado().valor.nombre }}</p>
<button type="button" (click)="cargar()">Cargar de la API</button>
</form>
`,
})
export class VistaSenal {
protected readonly formulario = crearFormulario();
protected readonly estado = estadoDe(this.formulario);
protected cargar() {
setTimeout(() => this.formulario.patchValue({ nombre: 'Marta' }), 100);
}
}

Figura 4. A la izquierda, el <input> se actualiza porque el adaptador escribe directamente en el DOM, pero la plantilla se queda en “Ana”. A la derecha, la señal avisa y todo coincide.
Esto desconcierta la primera vez: el campo muestra el dato nuevo, así que el modelo cambió y la escritura al DOM funcionó. Lo que no se ejecutó fue la revisión de la plantilla. Y no depende de OnPush: en una aplicación zoneless, el temporizador tampoco avisa a un componente Eager.
Un puente: estadoDe
La solución es convertir los eventos del control en una señal. La propiedad events emite cualquier cambio (valor, estado, touched, pristine, envío o reinicio), así que nos sirve una función de utilidad:
// src/app/formularios/estado-de.ts
import { Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { AbstractControl, FormControlStatus, ValidationErrors } from '@angular/forms';
import { map } from 'rxjs';
export interface EstadoControl<T> {
valor: T;
estado: FormControlStatus;
errores: ValidationErrors | null;
tocado: boolean;
modificado: boolean;
}
/** Foto del control en forma de señal. Llámala en un contexto de inyección. */
export function estadoDe<T>(control: AbstractControl<unknown, T>): Signal<EstadoControl<T>> {
const foto = (): EstadoControl<T> => ({
valor: control.getRawValue(),
estado: control.status,
errores: control.errors,
tocado: control.touched,
modificado: control.dirty,
});
return toSignal(control.events.pipe(map(foto)), { initialValue: foto() });
}
Como toSignal usa DestroyRef, se da de baja sola y necesita un contexto de inyección, de ahí que se llame al inicializar un campo de la clase. Con esto puedes escribir estado().estado === 'PENDING' o derivar con computed.
💡 Consejo: Cuando solo necesites el valor, basta con
toSignal(control.valueChanges, { initialValue: control.value }).
Un detalle de orden que causa errores difíciles de localizar: un control emite su valueChanges antes de avisar a su padre. Dentro de la suscripción a un hijo, el value del grupo todavía es el anterior:
const grupo = new FormGroup({ oficio: new FormControl('ANALISTA', { nonNullable: true }) });
grupo.controls.oficio.valueChanges.subscribe((nuevo) => {
console.log(nuevo, grupo.value.oficio, grupo.getRawValue().oficio);
});
grupo.controls.oficio.setValue('VENDEDOR');
VENDEDOR ANALISTA VENDEDOR
getRawValue() lee directamente de los hijos, y por eso ya trae el dato nuevo. Es otro motivo para preferirlo.
El componente de mensajes, bien hecho 🔄 v22.0
🔄 Cambia en Angular 22.0. Los componentes son OnPush por defecto, y un hijo que lee un
AbstractControlporinput()no se refresca, porque la referencia del control no cambia. En la LTS (Angular 21) el valor por defecto esEagery el hijo sí se refresca con el padre. Si usas la LTS: el código de abajo funciona igual, y es la opción recomendada, porque no depende de la estrategia del componente.
Ahora se entiende el fallo de ErrorCampoRoto. Al salir del campo, el evento blur marca el componente padre. El hijo recibe siempre el mismo objeto control, su input() no cambia, y OnPush lo salta. En una prueba real, con el campo obligatorio vacío, después de salir de él el hijo seguía diciendo que el control no estaba tocado.
La solución es que el hijo escuche los eventos del control. Esta es la versión definitiva, que usaremos en el resto del módulo:
// src/app/formularios/error-campo.ts
import { Component, computed, effect, input, signal } from '@angular/core';
import { AbstractControl, ValidationErrors } from '@angular/forms';
type Traductor = (detalle: any) => string;
const MENSAJES: Record<string, Traductor> = {
required: () => 'Este campo es obligatorio',
email: () => 'Escribe un email válido',
min: (e) => `El valor mínimo es ${e.min}`,
max: (e) => `El valor máximo es ${e.max}`,
minlength: (e) => `Escribe al menos ${e.requiredLength} caracteres`,
maxlength: (e) => `Escribe como mucho ${e.requiredLength} caracteres`,
pattern: () => 'El formato no es correcto',
emailOcupado: () => 'Ese email ya lo usa otro empleado',
fueraDeRango: (e) => `Debe estar entre ${e.minimo} y ${e.maximo}`,
soloEspacios: () => 'No puede contener solo espacios',
importeNoValido: () => 'Escribe un importe válido, como 1250,50',
servidor: (texto) => texto,
};
@Component({
selector: 'app-error-campo',
template: `
@if (mensaje(); as texto) {
<p class="error" role="alert">{{ texto }}</p>
}
`,
})
export class ErrorCampo {
readonly control = input.required<AbstractControl>();
private readonly version = signal(0);
protected readonly mensaje = computed(() => {
this.version(); // se recalcula con cada evento del control
const control = this.control();
return control.touched ? primerMensaje(control.errors) : null;
});
constructor() {
effect((alLimpiar) => {
const suscripcion = this.control().events.subscribe(() => {
this.version.update((v) => v + 1);
});
alLimpiar(() => suscripcion.unsubscribe());
});
}
}
function primerMensaje(errores: ValidationErrors | null): string | null {
const clave = Object.keys(errores ?? {})[0];
if (!clave) return null;
const traducir = MENSAJES[clave];
return traducir ? traducir(errores![clave]) : `Error: ${clave}`;
}
El truco de version es una señal que no guarda nada útil: solo sirve para que computed vuelva a calcularse. Como control es un input(), el effect se rehace si el padre pasa otro control, y alLimpiar cancela la suscripción anterior.
Con él, el alta queda limpia, y el formulario se reutiliza gracias al servicio de la sección 4:
// src/app/formularios/alta-validada.ts
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { ErrorCampo } from './error-campo';
import { FormularioEmpleado } from './formulario-empleado';
@Component({
selector: 'app-alta-validada',
imports: [ReactiveFormsModule, ErrorCampo],
template: `
<form [formGroup]="formulario" (ngSubmit)="enviar()">
<label>
Nombre <input formControlName="nombre" />
<app-error-campo [control]="formulario.controls.nombre" />
</label>
<label>
Email <input type="email" formControlName="email" />
<app-error-campo [control]="formulario.controls.email" />
</label>
<label>
Teléfono <input formControlName="telefono" />
<app-error-campo [control]="formulario.controls.telefono" />
</label>
<label>
Salario <input type="number" formControlName="salario" />
<app-error-campo [control]="formulario.controls.salario" />
</label>
<button type="submit">Guardar</button>
@if (resultado(); as texto) {
<p class="ok">{{ texto }}</p>
}
</form>
`,
})
export class AltaValidada {
protected readonly formulario = inject(FormularioEmpleado).crear();
protected readonly resultado = signal<string | null>(null);
protected enviar() {
if (this.formulario.invalid) {
this.formulario.markAllAsTouched();
return;
}
this.resultado.set(`Guardado: ${this.formulario.getRawValue().nombre}`);
}
}

Figura 5. markAllAsTouched() hace que cada ErrorCampo reciba su evento y enseñe el mensaje.
Quizá te preguntes por qué no aparece la burbuja de error del navegador con type="email". Angular añade el atributo novalidate a cada <form>, salvo que le pongas ngNativeValidate, para que la validación sea solo suya.
control.events: todos los cambios en un sitio
events emite objetos de distintas clases, y filtras con instanceof:
| Clase | Cuándo se emite | Dato |
|---|---|---|
ValueChangeEvent | Cambia el valor | value |
StatusChangeEvent | Cambia el estado | status |
PristineChangeEvent | Cambia pristine | pristine |
TouchedChangeEvent | Cambia touched | touched |
FormSubmittedEvent | Se envía el formulario | |
FormResetEvent | Se llama a reset |
Todas tienen source, el control donde se originó el cambio. Es muy útil en la raíz, donde llegan los eventos de todos los hijos:
// src/app/formularios/registro-eventos.ts
import { Component, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormControl, FormGroup, FormResetEvent, FormSubmittedEvent,
ReactiveFormsModule, StatusChangeEvent, TouchedChangeEvent, Validators,
} from '@angular/forms';
@Component({
selector: 'app-registro-eventos',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="formulario" (ngSubmit)="0">
<label>Oficio <input id="oficio" formControlName="oficio" /></label>
<button type="submit">Enviar</button>
<button type="button" (click)="formulario.reset()">Reiniciar</button>
</form>
<pre id="registro">{{ registro().join('\n') }}</pre>
`,
})
export class RegistroEventos {
protected readonly registro = signal<string[]>([]);
protected readonly formulario = new FormGroup({
oficio: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
});
constructor() {
this.formulario.events.pipe(takeUntilDestroyed()).subscribe((evento) => {
if (evento instanceof StatusChangeEvent) this.anotar(`estado: ${evento.status}`);
if (evento instanceof TouchedChangeEvent) this.anotar(`tocado: ${evento.touched}`);
if (evento instanceof FormSubmittedEvent) this.anotar('enviado');
if (evento instanceof FormResetEvent) this.anotar('reiniciado');
});
}
private anotar(texto: string) {
this.registro.update((lista) => [...lista, texto]);
}
}
Si escribes “x”, sales del campo, pulsas Enviar y luego Reiniciar, el registro real es:
estado: VALID
tocado: true
enviado
tocado: false
estado: INVALID
reiniciado
updateOn: cuándo se actualiza el modelo
Por defecto, el modelo cambia con cada pulsación ('change'). Con updateOn puedes retrasar el valor y la validación hasta que el usuario salga del campo ('blur') o envíe ('submit'):
// src/app/formularios/actualizar-al-salir.ts
import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-actualizar-al-salir',
imports: [ReactiveFormsModule],
template: `
<label>Oficio <input id="oficio" [formControl]="oficio" /></label>
<p id="visto">Modelo: {{ valor() }}</p>
`,
})
export class ActualizarAlSalir {
protected readonly oficio = new FormControl('', { nonNullable: true, updateOn: 'blur' });
protected readonly valor = toSignal(this.oficio.valueChanges, { initialValue: '' });
}
También se puede fijar para todo un grupo con fb.group({...}, { updateOn: 'blur' }). Es un ahorro real con validadores costosos, aunque en las búsquedas suele ser mejor un debounce.
8. Validadores propios
Un validador es una función que recibe un control y devuelve null si todo va bien, o un objeto con los errores. Su tipo es ValidatorFn. La mayoría se escriben como una fábrica, una función que recibe la configuración y devuelve el validador:
// src/app/formularios/validadores.ts
import { AbstractControl, FormArray, ValidationErrors, ValidatorFn } from '@angular/forms';
/** El número debe estar entre dos límites. Vacío se da por bueno. */
export function rangoSalarial(minimo: number, maximo: number): ValidatorFn {
return (control: AbstractControl<number | null>): ValidationErrors | null => {
const valor = control.value;
if (valor === null) return null; // lo vacío es cosa de Validators.required
return valor >= minimo && valor <= maximo ? null : { fueraDeRango: { minimo, maximo, valor } };
};
}
/** Rechaza textos que solo tienen espacios: required los daría por buenos. */
export const sinSoloEspacios: ValidatorFn = (control) => {
const valor = control.value as string;
return valor && valor.trim() === '' ? { soloEspacios: true } : null;
};
/** Validador de grupo: la fecha de fin no puede ser anterior a la de inicio. */
export function fechasOrdenadas(campoInicio: string, campoFin: string): ValidatorFn {
return (grupo: AbstractControl): ValidationErrors | null => {
const inicio = grupo.get(campoInicio)?.value as string | undefined;
const fin = grupo.get(campoFin)?.value as string | undefined;
if (!inicio || !fin) return null;
return inicio <= fin ? null : { fechasInvertidas: { inicio, fin } };
};
}
/** Validador de FormArray: no se puede repetir un valor de un campo. */
export function sinRepetidos(campo: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const array = control as FormArray;
const valores = array.controls
.map((hijo) => hijo.get(campo)?.value)
.filter((v) => v !== null && v !== undefined);
return new Set(valores).size === valores.length ? null : { repetido: { campo } };
};
}
const rango = rangoSalarial(1000, 5000);
console.log(JSON.stringify(rango(new FormControl(800))));
console.log(JSON.stringify(rango(new FormControl(2000))));
console.log(JSON.stringify(rango(new FormControl(null))));
console.log(JSON.stringify(sinSoloEspacios(new FormControl(' '))));
const fechas = new FormGroup(
{ inicio: new FormControl('2026-06-01'), fin: new FormControl('2026-01-15') },
{ validators: fechasOrdenadas('inicio', 'fin') },
);
console.log(fechas.status, JSON.stringify(fechas.errors));
console.log('hijo fin', fechas.controls.fin.status);
{"fueraDeRango":{"minimo":1000,"maximo":5000,"valor":800}}
null
null
{"soloEspacios":true}
INVALID {"fechasInvertidas":{"inicio":"2026-06-01","fin":"2026-01-15"}}
hijo fin VALID
Tres convenios que merece la pena seguir:
- Lo vacío es válido. Cada validador comprueba una sola cosa. Si
rangoSalarialtambién rechazara lo vacío, no podrías usarlo en un campo opcional. - Pon datos en el error.
{ fueraDeRango: { minimo, maximo, valor } }permite construir el mensaje sin repetir los límites. - Una función pura. Sin efectos secundarios, sin leer servicios. Así es trivial de probar.
Validadores de grupo
La última línea de la salida anterior es importante: el validador de grupo pone el error en el grupo, y los hijos siguen VALID. Por tanto el CSS ng-invalid no marcará los campos, y el mensaje se muestra aparte. Esta es la forma habitual, en un formulario de proyecto (de la tabla proyectos de EMPRESA) que además tiene un campo condicional:
// src/app/formularios/alta-proyecto.ts
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ErrorCampo } from './error-campo';
import { estadoDe } from './estado-de';
import { fechasOrdenadas } from './validadores';
@Component({
selector: 'app-alta-proyecto',
imports: [ReactiveFormsModule, ErrorCampo],
template: `
<form [formGroup]="formulario" (ngSubmit)="enviar()">
<label>
Nombre <input formControlName="nombre" />
<app-error-campo [control]="formulario.controls.nombre" />
</label>
<div class="fila">
<label>Inicio <input type="date" formControlName="fechaInicio" /></label>
<label>Fin <input type="date" formControlName="fechaFin" /></label>
</div>
@if (estado().errores?.['fechasInvertidas']) {
<p class="error" role="alert">La fecha de fin es anterior a la de inicio</p>
}
<label><input type="checkbox" formControlName="conPresupuesto" /> Tiene presupuesto</label>
@if (estado().valor.conPresupuesto) {
<label>
Presupuesto <input type="number" formControlName="presupuesto" />
<app-error-campo [control]="formulario.controls.presupuesto" />
</label>
}
<button type="submit">Crear</button>
</form>
`,
})
export class AltaProyecto {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly formulario = this.fb.group(
{
nombre: ['', Validators.required],
fechaInicio: [''],
fechaFin: [''],
conPresupuesto: [false],
presupuesto: this.fb.control<number | null>(null),
},
{ validators: fechasOrdenadas('fechaInicio', 'fechaFin') },
);
protected readonly estado = estadoDe(this.formulario);
constructor() {
const { conPresupuesto, presupuesto } = this.formulario.controls;
const reglas = [Validators.required, Validators.min(1)];
conPresupuesto.valueChanges.pipe(takeUntilDestroyed()).subscribe((activo) => {
if (activo) {
presupuesto.addValidators(reglas);
} else {
presupuesto.removeValidators(reglas);
presupuesto.reset();
}
presupuesto.updateValueAndValidity(); // imprescindible: si no, no se recalcula
});
}
protected enviar() {
this.formulario.markAllAsTouched();
}
}
Validación condicional
El constructor anterior muestra cómo cambiar las reglas sobre la marcha:
| Método | Efecto |
|---|---|
setValidators(v) | Sustituye todos los validadores |
addValidators(v) | Añade sin tocar los demás |
removeValidators(v) | Quita por referencia |
hasValidator(v) | Comprueba por referencia |
clearValidators() | Los quita todos |
Hay dos trampas. Cambiar los validadores no revalida: tienes que llamar a updateValueAndValidity(). Y removeValidators compara por referencia: Validators.required es una función estática, siempre la misma, pero Validators.min(1) crea una función nueva en cada llamada. Por eso el ejemplo guarda ambas en el array reglas y usa la misma referencia para añadir y quitar:
const campo = new FormControl(0, { nonNullable: true, validators: [Validators.min(1)] });
console.log(campo.valid);
campo.removeValidators(Validators.min(1));
campo.updateValueAndValidity();
console.log(campo.valid);
const minimo = Validators.min(1);
campo.setValidators(minimo);
campo.removeValidators(minimo);
campo.updateValueAndValidity();
console.log(campo.valid);
false
false
true
La alternativa más sencilla, cuando el campo solo tiene sentido en una condición, es desactivarlo: un control disabled no se valida. Queda así:
activo ? presupuesto.enable() : presupuesto.disable();
💡 Consejo: Otra opción es un validador de grupo que lea los dos campos. Evita suscripciones y estado, y siempre ve los dos valores. Lo verás en el ejercicio 5.
9. Validadores asíncronos
Algunas comprobaciones solo las puede hacer el servidor: si un email ya está en uso, si un NIF existe… Un validador asíncrono (AsyncValidatorFn) devuelve un Observable o una Promise con el mismo resultado: null o un objeto de errores.
Figura 6. Los validadores asíncronos solo se lanzan si todos los síncronos han pasado. Mientras tanto, el control está en PENDING.
Vamos a preguntar a la API si existe un empleado con ese email. Primero lo común a todo lo que usa la API, igual que en el módulo 12:
// src/app/empresa/api-url.ts
import { InjectionToken } from '@angular/core';
export const API_URL = new InjectionToken<string>('API_URL', {
factory: () => 'http://localhost:3000/api',
});
// src/app/empresa/modelos.ts
export interface Pagina<T> {
contenido: T[];
pagina: number;
tamano: number;
total: number;
paginas: number;
}
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
email: string;
telefono: string | null;
oficio: string;
salario: number;
comision: number | null;
deptId: number | null;
departamento: string | null;
}
export type DatosEmpleado = Omit<Empleado, 'empId' | 'departamento'>;
export interface Producto {
productoId: number;
nombre: string;
precio: number;
stock: number;
}
export interface Cliente {
clienteId: number;
nombre: string;
}
export interface ErrorApi {
status: number;
mensaje: string;
detalles?: Record<string, string>;
}
Y el validador:
// src/app/formularios/email-libre.ts
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { AsyncValidatorFn } from '@angular/forms';
import { catchError, map, of, switchMap, timer } from 'rxjs';
import { API_URL } from '../empresa/api-url';
import { Empleado, Pagina } from '../empresa/modelos';
/** Llámala en un contexto de inyección. idPropio excluye al empleado que se edita. */
export function emailLibre(idPropio?: () => number | null): AsyncValidatorFn {
const http = inject(HttpClient);
const url = `${inject(API_URL)}/empleados`;
return (control) => {
const email = (control.value as string).trim().toLowerCase();
return timer(400).pipe(
switchMap(() => http.get<Pagina<Empleado>>(url, { params: { q: email } })),
map(({ contenido }) => {
const dueno = contenido.find((e) => e.email.toLowerCase() === email);
return dueno && dueno.empId !== idPropio?.() ? { emailOcupado: true } : null;
}),
catchError(() => of(null)), // si la API falla, que decida el servidor al guardar
);
};
}
Merece la pena mirarlo línea a línea:
injectfuera de la función interna. La fábrica se ejecuta en un contexto de inyección, al crear el formulario. La función interna se ejecuta mucho después, y ahíinjectfallaría. Por eso se capturanhttpyurlantes.timer(400)es el debounce. Cada vez que cambia el valor, Angular cancela la suscripción al validador anterior y crea otra. Eltimerdel intento anterior muere antes de disparar, así que la petición solo sale cuando el usuario lleva 400 ms sin escribir. UndebounceTimeno funcionaría aquí, porque cada validación crea un observable nuevo con un solo valor.catchError. Si la API está caída, un error sin capturar dejaría el control con un fallo en consola. Aquí se decide no bloquear al usuario: el servidor volverá a comprobarlo al guardar.- El observable debe completarse. Si pasas los validadores en un array, Angular los combina con
forkJoin, que espera a que terminen todos.http.getse completa solo, pero un observable que emite y no termina, como unvalueChanges, deja el control enPENDINGpara siempre. Lo hemos comprobado: ese mismo observable salePENDINGdentro de un array yVALIDcomo validador suelto. No dependas de esa diferencia y añadefirst().
El uso se indica en la tercera posición del array, o con asyncValidators:
// src/app/formularios/alta-con-email.ts
import { Component, computed, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { emailLibre } from './email-libre';
import { ErrorCampo } from './error-campo';
import { estadoDe } from './estado-de';
@Component({
selector: 'app-alta-con-email',
imports: [ReactiveFormsModule, ErrorCampo],
template: `
<form [formGroup]="formulario" (ngSubmit)="enviar()">
<label>
Email <input id="email" formControlName="email" />
@if (comprobando()) {
<p class="aviso">Comprobando si está libre...</p>
}
<app-error-campo [control]="formulario.controls.email" />
</label>
<p>Estado del control: <strong id="estado">{{ estadoEmail().estado }}</strong></p>
<button type="submit" [disabled]="comprobando()">Guardar</button>
</form>
`,
})
export class AltaConEmail {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly formulario = this.fb.group({
email: ['', [Validators.required, Validators.email], emailLibre()],
});
protected readonly estadoEmail = estadoDe(this.formulario.controls.email);
protected readonly comprobando = computed(() => this.estadoEmail().estado === 'PENDING');
protected enviar() {
this.formulario.markAllAsTouched();
}
}

Figura 7. Durante la comprobación el control está en PENDING: ni válido ni inválido.

Figura 8. La API responde y el error aparece gracias a los eventos del control.
Ejecutado de verdad, al escribir un email completo de 21 caracteres pulsación a pulsación, las peticiones a la API fueron estas:
Pulsaciones: 21
Peticiones: 1
GET /api/empleados?q=ana.garcia@empresa.es
Cuatro detalles que se te pueden volver en contra:
- Mientras el control está en
PENDING,valideinvalidson ambos falsos. Si escribesif (formulario.invalid) return;, el formulario se puede enviar mientras se comprueba. Por eso el botón se desactiva concomprobando(), o bien compruebasformulario.valid. - Sin
touchedno se ve el error. Si el usuario no ha salido del campo,ErrorCampono muestra nada. Es lo que quieres en la mayoría de los casos. - Un
setValuetambién lo lanza. Al rellenar un formulario de edición con datos existentes se dispara el validador. Por eso existeidPropio: para que tu propio email no cuente como ocupado. - Reduce las llamadas con
updateOn: 'blur'si la comprobación es costosa:this.fb.control('', { validators: [...], asyncValidators: [emailLibre()], updateOn: 'blur' }).
⚠️ Importante: Un validador en el cliente es una ayuda, no una garantía. Entre la comprobación y el guardado, otra persona puede haber ocupado ese email. La API debe comprobarlo otra vez, y tú debes saber mostrar su respuesta, como en la sección 13.
10. FormArray: listas de controles
FormArray guarda una lista ordenada de controles. Se usa cuando el usuario decide cuántos elementos hay: teléfonos, líneas de pedido, miembros de un equipo.
Una lista de valores
// src/app/formularios/telefonos.ts
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ErrorCampo } from './error-campo';
@Component({
selector: 'app-telefonos',
imports: [ReactiveFormsModule, ErrorCampo],
template: `
<form [formGroup]="formulario">
<div formArrayName="telefonos">
@for (telefono of telefonos.controls; track telefono; let i = $index) {
<div class="fila">
<label>Teléfono {{ i + 1 }} <input [formControlName]="i" /></label>
<button type="button" (click)="telefonos.removeAt(i)">Quitar</button>
</div>
<app-error-campo [control]="telefono" />
}
</div>
<button type="button" (click)="anadir()">Añadir teléfono</button>
</form>
`,
})
export class Telefonos {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly formulario = this.fb.group({
telefonos: this.fb.array([this.crearTelefono('600100100')]),
});
protected readonly telefonos = this.formulario.controls.telefonos;
private crearTelefono(valor = '') {
return this.fb.control(valor, [Validators.required, Validators.pattern(/^\d{9}$/)]);
}
protected anadir() {
this.telefonos.push(this.crearTelefono());
}
}
Compara con los grupos: formArrayName indica el array y, como los hijos no tienen nombre sino posición, se enlazan con [formControlName]="i", con corchetes porque i es un número y una expresión.
⚠️ Importante: En
@for, usatrack control, el objeto, y notrack $index. Si quitas el teléfono 1 contrack $index, Angular reutiliza los elementos de las posiciones y los traslada de control. Con el objeto, destruye exactamente la fila que has quitado, y conserva el foco y lo que tuviera cada una.
La API de FormArray es la de un array:
const a = new FormArray([new FormControl('uno', { nonNullable: true })]);
a.push(new FormControl('dos', { nonNullable: true }));
a.insert(0, new FormControl('cero', { nonNullable: true }));
console.log(a.length, a.at(-1).value, JSON.stringify(a.value));
a.removeAt(1);
console.log(JSON.stringify(a.value));
a.clear();
console.log(a.length);
const conMinimo = new FormArray<FormControl<string>>([], Validators.minLength(1));
const conRequerido = new FormArray<FormControl<string>>([], Validators.required);
console.log('minLength', conMinimo.valid, 'required', conRequerido.valid);
3 dos ["cero","uno","dos"]
["cero","dos"]
0
minLength true required false
La última línea es una trampa conocida: Validators.minLength(1) da por bueno un array vacío, porque lo vacío se considera “sin valor”. Para exigir un elemento como mínimo, usa Validators.required, que sí comprueba la longitud del array.
Una lista de grupos: el pedido
Lo habitual es un array de grupos. Este formulario crea un pedido contra EMPRESA, con cliente, líneas y total calculado. También usa el servicio de sesión que verás en la sección 13.
// src/app/formularios/pedido-nuevo.ts
import { CurrencyPipe } from '@angular/common';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
FormControl, FormGroup, NonNullableFormBuilder, ReactiveFormsModule, Validators,
} from '@angular/forms';
import { map, startWith, switchMap } from 'rxjs';
import { API_URL } from '../empresa/api-url';
import { Cliente, ErrorApi, Pagina, Producto } from '../empresa/modelos';
import { Sesion } from '../empresa/sesion';
import { ErrorCampo } from './error-campo';
import { estadoDe } from './estado-de';
import { sinRepetidos } from './validadores';
interface GrupoLinea {
productoId: FormControl<number | null>;
cantidad: FormControl<number>;
}
@Component({
selector: 'app-pedido-nuevo',
imports: [ReactiveFormsModule, CurrencyPipe, ErrorCampo],
template: `
<form [formGroup]="formulario" (ngSubmit)="enviar()">
<label>
Cliente
<select formControlName="clienteId">
<option [ngValue]="null">Elige un cliente</option>
@for (c of clientes(); track c.clienteId) {
<option [ngValue]="c.clienteId">{{ c.nombre }}</option>
}
</select>
<app-error-campo [control]="formulario.controls.clienteId" />
</label>
<div formArrayName="lineas">
@for (linea of lineas.controls; track linea; let i = $index) {
<div class="fila" [formGroupName]="i">
<select formControlName="productoId" aria-label="Producto">
<option [ngValue]="null">Elige un producto</option>
@for (p of productos(); track p.productoId) {
<option [ngValue]="p.productoId">{{ p.nombre }}</option>
}
</select>
<input type="number" min="1" formControlName="cantidad" aria-label="Cantidad" />
<span>{{ subtotal(i) | currency: 'EUR' }}</span>
<button type="button" (click)="lineas.removeAt(i)">Quitar</button>
</div>
}
</div>
@if (estadoLineas().errores?.['repetido']) {
<p class="error" role="alert">Hay un producto repetido en dos líneas</p>
}
@if (estadoLineas().errores?.['required'] && estadoLineas().tocado) {
<p class="error" role="alert">El pedido necesita al menos una línea</p>
}
<button type="button" (click)="anadirLinea()">Añadir línea</button>
<p>Total: <strong>{{ total() | currency: 'EUR' }}</strong></p>
<button type="submit" [disabled]="enviando()">Crear pedido</button>
@if (mensaje(); as texto) {
<p role="status">{{ texto }}</p>
}
</form>
`,
})
export class PedidoNuevo {
private readonly fb = inject(NonNullableFormBuilder);
private readonly http = inject(HttpClient);
private readonly sesion = inject(Sesion);
private readonly api = inject(API_URL);
protected readonly clientes = toSignal(
this.http.get<Pagina<Cliente>>(`${this.api}/clientes`, { params: { tamano: 100 } })
.pipe(map((p) => p.contenido)),
{ initialValue: [] },
);
protected readonly productos = toSignal(
this.http.get<Pagina<Producto>>(`${this.api}/productos`, { params: { tamano: 100 } })
.pipe(map((p) => p.contenido)),
{ initialValue: [] },
);
protected readonly formulario = this.fb.group({
clienteId: this.fb.control<number | null>(null, Validators.required),
lineas: this.fb.array<FormGroup<GrupoLinea>>([], {
validators: [Validators.required, sinRepetidos('productoId')],
}),
});
protected readonly lineas = this.formulario.controls.lineas;
protected readonly estadoLineas = estadoDe(this.lineas);
protected readonly enviando = signal(false);
protected readonly mensaje = signal<string | null>(null);
private readonly valorLineas = toSignal(
this.lineas.valueChanges.pipe(
startWith(null),
map(() => this.lineas.getRawValue()),
),
{ requireSync: true },
);
protected readonly total = computed(() =>
this.valorLineas().reduce((suma, _, i) => suma + this.subtotal(i), 0),
);
constructor() {
this.anadirLinea();
}
protected subtotal(indice: number): number {
const linea = this.valorLineas()[indice];
const producto = this.productos().find((p) => p.productoId === linea?.productoId);
return producto ? producto.precio * linea.cantidad : 0;
}
protected anadirLinea() {
this.lineas.push(
this.fb.group<GrupoLinea>({
productoId: this.fb.control<number | null>(null, Validators.required),
cantidad: this.fb.control(1, [Validators.required, Validators.min(1)]),
}),
);
}
protected enviar() {
if (this.formulario.invalid) {
this.formulario.markAllAsTouched();
return;
}
this.enviando.set(true);
this.mensaje.set(null);
this.sesion.autorizar((headers) =>
this.http.post<{ pedidoId: number }>(`${this.api}/pedidos`, this.formulario.getRawValue(), { headers }),
).subscribe({
next: ({ pedidoId }) => {
this.enviando.set(false);
this.mensaje.set(`Pedido ${pedidoId} creado`);
this.lineas.clear();
this.formulario.reset();
},
error: (e: HttpErrorResponse) => {
this.enviando.set(false);
this.mensaje.set((e.error as ErrorApi).mensaje);
},
});
}
}

Figura 9. Un FormArray de grupos. Subtotales y total se derivan del valor del array con computed.
Veamos las decisiones del código:
- Tipo
GrupoLinea.this.fb.array<FormGroup<GrupoLinea>>([])declara qué contiene un array que nace vacío. startWith(null)yrequireSync. ConviertenvalueChangesen una señal que ya tiene valor inicial, sin escribir el valor a mano.requireSyncfunciona porquestartWithemite de inmediato.totalderiva del formulario. No se guarda en ningún sitio: el total no es un dato, se calcula. Es el patrón de siempre concomputed.[ngValue]y no[value].valueconvierte a texto: elFormControlrecibiría"1001"en vez de1001, y la API lo rechazaría.[ngValue]conserva el número y elnull.this.lineas.clear()antes dereset().resetdeja los hijos existentes con sus valores por defecto, pero no cambia cuántos hay.- El tipo del array vacío se indica siempre. Con
this.fb.array([]), TypeScript no tiene de dónde deducirlo, y pierdes la comprobación deproductoIdycantidaden cada fila.
Para que currency escriba 1.837,80 € y no €1,837.80, la aplicación registra el idioma con registerLocaleData(localeEs) y { provide: LOCALE_ID, useValue: 'es-ES' }, como viste en el módulo 07.
💡 Consejo: Como
lineases unFormArray, puedes aplicarle validadores propios.sinRepetidosrecorre sus controles, como cualquier array. El error queda en el array, y por eso se lee conestadoLineas, no en cada fila.
Un FormArray en la raíz
Desde Angular 21, la directiva [formArray] permite que la raíz del formulario sea un array, sin un grupo que lo envuelva. Es útil cuando el formulario entero es una lista:
// src/app/formularios/lista-etiquetas.ts
import { Component, inject, signal } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-lista-etiquetas',
imports: [ReactiveFormsModule],
template: `
<form [formArray]="etiquetas" (ngSubmit)="enviar()">
@for (etiqueta of etiquetas.controls; track etiqueta; let i = $index) {
<label>Etiqueta {{ i + 1 }} <input [formControlName]="i" /></label>
}
<button type="submit">Guardar</button>
</form>
<p id="salida">{{ salida() }}</p>
`,
})
export class ListaEtiquetas {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly etiquetas = this.fb.array([
this.fb.control('urgente', Validators.required),
this.fb.control('cliente', Validators.required),
]);
protected readonly salida = signal('');
protected enviar() {
this.salida.set(this.etiquetas.getRawValue().join(' + '));
}
}
11. Formularios dinámicos
Hasta aquí, la forma del formulario se conocía al escribir el código. A veces la dicta una configuración: los campos dependen del tipo de cliente, o llegan de una tabla. La idea es siempre la misma: una lista de descripciones de campo, una función que construye los controles y una plantilla que dibuja cada tipo.
// src/app/formularios/formulario-dinamico.ts
import { Component, computed, inject, signal } from '@angular/core';
import {
FormControl, FormRecord, NonNullableFormBuilder, ReactiveFormsModule, ValidatorFn, Validators,
} from '@angular/forms';
import { ErrorCampo } from './error-campo';
export type ValorCampo = string | number | boolean | null;
export interface CampoDinamico {
clave: string;
etiqueta: string;
tipo: 'texto' | 'numero' | 'lista' | 'casilla';
obligatorio?: boolean;
minimo?: number;
patron?: RegExp;
opciones?: string[];
inicial?: ValorCampo;
}
const CAMPOS: Record<string, CampoDinamico[]> = {
particular: [
{ clave: 'nombre', etiqueta: 'Nombre completo', tipo: 'texto', obligatorio: true },
{ clave: 'ciudad', etiqueta: 'Ciudad', tipo: 'lista', opciones: ['Madrid', 'Valencia', 'Sevilla'] },
{ clave: 'publicidad', etiqueta: 'Acepta publicidad', tipo: 'casilla', inicial: false },
],
empresa: [
{ clave: 'razonSocial', etiqueta: 'Razón social', tipo: 'texto', obligatorio: true },
{ clave: 'nif', etiqueta: 'NIF', tipo: 'texto', obligatorio: true, patron: /^[A-Z]\d{8}$/ },
{ clave: 'limiteCredito', etiqueta: 'Límite de crédito', tipo: 'numero', minimo: 0, inicial: 5000 },
{ clave: 'provincia', etiqueta: 'Provincia', tipo: 'lista', opciones: ['Madrid', 'Valencia'] },
],
};
function reglas(campo: CampoDinamico): ValidatorFn[] {
return [
...(campo.obligatorio ? [Validators.required] : []),
...(campo.minimo !== undefined ? [Validators.min(campo.minimo)] : []),
...(campo.patron ? [Validators.pattern(campo.patron)] : []),
];
}
@Component({
selector: 'app-formulario-dinamico',
imports: [ReactiveFormsModule, ErrorCampo],
template: `
<label>
Tipo de cliente
<select #tipo (change)="clase.set(tipo.value)">
<option value="particular">Particular</option>
<option value="empresa">Empresa</option>
</select>
</label>
<form [formGroup]="formulario()" (ngSubmit)="enviar()">
@for (campo of campos(); track campo.clave) {
<label>
{{ campo.etiqueta }}
@switch (campo.tipo) {
@case ('texto') {
<input [formControlName]="campo.clave" />
}
@case ('numero') {
<input type="number" [formControlName]="campo.clave" />
}
@case ('casilla') {
<input type="checkbox" [formControlName]="campo.clave" />
}
@case ('lista') {
<select [formControlName]="campo.clave">
<option [ngValue]="null">Sin indicar</option>
@for (opcion of campo.opciones; track opcion) {
<option [ngValue]="opcion">{{ opcion }}</option>
}
</select>
}
}
<app-error-campo [control]="formulario().controls[campo.clave]" />
</label>
}
<button type="submit">Guardar</button>
</form>
@if (salida(); as texto) {
<pre>{{ texto }}</pre>
}
`,
})
export class FormularioDinamico {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly clase = signal('particular');
protected readonly salida = signal<string | null>(null);
protected readonly campos = computed(() => CAMPOS[this.clase()]);
protected readonly formulario = computed(() => {
const controles: Record<string, FormControl<ValorCampo>> = {};
for (const campo of this.campos()) {
controles[campo.clave] = this.fb.control<ValorCampo>(campo.inicial ?? null, reglas(campo));
}
return this.fb.record(controles);
});
protected enviar() {
const formulario: FormRecord<FormControl<ValorCampo>> = this.formulario();
if (formulario.invalid) {
formulario.markAllAsTouched();
return;
}
this.salida.set(JSON.stringify(formulario.getRawValue(), null, 2));
}
}

Figura 10. Al cambiar el tipo de cliente, computed crea un FormRecord nuevo y la plantilla dibuja sus campos.
La pieza clave es que formulario es un computed: cuando cambia clase, se crea un formulario nuevo y [formGroup] se enlaza con él. Las directivas formControlName se destruyen y se crean porque el @for tiene claves distintas. Los campos de CAMPOS podrían llegar perfectamente de una API en JSON; la excepción es patron, que tendría que viajar como texto.
Para mantener valores entre configuraciones, en vez de crear un formulario nuevo, ve haciendo addControl y removeControl sobre el mismo FormRecord.
⚠️ Importante: Las plantillas dinámicas pierden la comprobación de tipos:
formulario().controls[campo.clave]podría serundefinedsi la configuración y los controles se desincronizan. Por eso conviene una única fuente, como aquí: los controles se generan de la misma lista que se dibuja. No uses formularios dinámicos si la forma del formulario se conoce de antemano: un formulario escrito a mano se entiende, se tipa y se depura mejor.
12. Controles propios
Quieres un selector de estrellas, un campo de importe que formatee en euros o un selector de empleados, y quieres usarlo con formControlName como si fuera un <input>. Para ello tu componente debe hacer de adaptador, igual que los de la figura 2.
ControlValueAccessor
La interfaz tiene tres métodos obligatorios y uno opcional:
| Método | Quién lo llama | Para qué |
|---|---|---|
writeValue(valor) | Angular | El modelo cambió: refleja el valor en tu vista |
registerOnChange(fn) | Angular | Te entrega la función a la que debes llamar cuando el usuario cambia el valor |
registerOnTouched(fn) | Angular | Te entrega la función a la que debes llamar cuando el usuario sale |
setDisabledState(d) | Angular (opcional) | El control se activó o se desactivó |
Y hay que registrarse con el token NG_VALUE_ACCESSOR, que es como las directivas encuentran al adaptador:
// src/app/formularios/valoracion.ts
import { Component, forwardRef, input, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-valoracion',
template: `
<div role="radiogroup" [attr.aria-label]="etiqueta()">
@for (n of [1, 2, 3, 4, 5]; track n) {
<button
type="button"
role="radio"
class="estrella"
[class.activa]="n <= valor()"
[attr.aria-checked]="n === valor()"
[attr.aria-label]="n + ' de 5'"
[disabled]="desactivado()"
(click)="elegir(n)"
(blur)="alTocar()"
>★</button>
}
</div>
`,
styles: `
.estrella { background: none; border: none; padding: 0 .1rem; font-size: 1.8rem; color: #cfd8dc; }
.estrella.activa { color: #ff7043; }
`,
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => Valoracion), multi: true },
],
})
export class Valoracion implements ControlValueAccessor {
readonly etiqueta = input('Valoración');
protected readonly valor = signal(0);
protected readonly desactivado = signal(false);
private alCambiar: (valor: number) => void = () => {};
protected alTocar: () => void = () => {};
writeValue(valor: number | null): void {
this.valor.set(valor ?? 0);
}
registerOnChange(fn: (valor: number) => void): void {
this.alCambiar = fn;
}
registerOnTouched(fn: () => void): void {
this.alTocar = fn;
}
setDisabledState(desactivado: boolean): void {
this.desactivado.set(desactivado);
}
protected elegir(n: number) {
this.valor.set(n);
this.alCambiar(n);
}
}
Paso a paso:
multi: trueporque el token admite varios adaptadores, y Angular elige el mejor: el tuyo gana a los integrados.forwardRefporque en el decorador la claseValoracionaún no está definida.useExistingpara que el adaptador sea la propia instancia del componente, y no una copia.- Los estados son señales.
writeValuelo llama Angular desde fuera de tu plantilla. En una aplicación OnPush y zoneless, si guardaras el valor en una propiedad normal, la vista no se enteraría: es la misma trampa de la sección 7. elegirescribe y avisa. Si te olvidas dealCambiar, verás la estrella marcada, pero el formulario no se enterará.alTocarenblur. Sin ella,touchednunca será cierto y los errores no aparecerán.
⚠️ Importante:
writeValuepuede recibirnull(por ejemplo, trasreset()), aunque tu control no lo espere. Trátalo siempre, como hacevalor ?? 0.
Validación dentro del control
Un control propio puede validarse a sí mismo. Un campo de importe en euros recibe texto (12.500,50) y guarda un número. Si el texto no se entiende, el modelo debe quedar en null, pero required no bastaría para distinguir “vacío” de “incorrecto”. La solución es implementar también Validator y registrarse en NG_VALIDATORS:
// src/app/formularios/importe-euros.ts
import { Component, forwardRef, signal } from '@angular/core';
import {
AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR,
ValidationErrors, Validator,
} from '@angular/forms';
const FORMATO = new Intl.NumberFormat('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@Component({
selector: 'app-importe-euros',
template: `
<span class="fila">
<input
inputmode="decimal"
aria-label="Importe en euros"
[value]="texto()"
[disabled]="desactivado()"
(input)="alEscribir($any($event.target).value)"
(blur)="alSalir()"
/>
<span>€</span>
</span>
`,
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ImporteEuros), multi: true },
{ provide: NG_VALIDATORS, useExisting: forwardRef(() => ImporteEuros), multi: true },
],
})
export class ImporteEuros implements ControlValueAccessor, Validator {
protected readonly texto = signal('');
protected readonly desactivado = signal(false);
private textoNoValido = false;
private alCambiar: (valor: number | null) => void = () => {};
private alTocar: () => void = () => {};
writeValue(valor: number | null): void {
this.textoNoValido = false;
this.texto.set(valor === null ? '' : FORMATO.format(valor));
}
registerOnChange(fn: (valor: number | null) => void): void {
this.alCambiar = fn;
}
registerOnTouched(fn: () => void): void {
this.alTocar = fn;
}
setDisabledState(desactivado: boolean): void {
this.desactivado.set(desactivado);
}
validate(_: AbstractControl): ValidationErrors | null {
return this.textoNoValido ? { importeNoValido: true } : null;
}
protected alEscribir(texto: string) {
this.texto.set(texto);
const numero = convertir(texto);
this.textoNoValido = texto.trim() !== '' && numero === null;
this.alCambiar(numero);
}
protected alSalir() {
const numero = convertir(this.texto());
if (numero !== null) this.texto.set(FORMATO.format(numero));
this.alTocar();
}
}
/** "12.500,50" => 12500.5. Devuelve null si no es un número. */
function convertir(texto: string): number | null {
const limpio = texto.trim().replaceAll('.', '').replace(',', '.');
if (limpio === '') return null;
const numero = Number(limpio);
return Number.isFinite(numero) ? numero : null;
}
Fíjate en que Intl.NumberFormat con es-ES escribe 1250,50, sin punto: en español, el separador de miles no se usa con números de cuatro cifras. Cuando el usuario escribe 12x, el modelo pasa a null y validate da el error importeNoValido. El orden importa: textoNoValido debe fijarse antes de llamar a alCambiar, porque alCambiar lanza la validación de forma síncrona.
Las dos piezas juntas, en una encuesta a clientes:
// src/app/formularios/encuesta.ts
import { JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ErrorCampo } from './error-campo';
import { estadoDe } from './estado-de';
import { ImporteEuros } from './importe-euros';
import { Valoracion } from './valoracion';
@Component({
selector: 'app-encuesta',
imports: [ReactiveFormsModule, JsonPipe, ErrorCampo, Valoracion, ImporteEuros],
template: `
<form [formGroup]="formulario" (ngSubmit)="enviar()">
<div>
<p>¿Cómo valoras el servicio?</p>
<app-valoracion formControlName="nota" etiqueta="Valoración del servicio" />
<app-error-campo [control]="formulario.controls.nota" />
</div>
<label>
Importe de la compra
<app-importe-euros formControlName="importe" />
<app-error-campo [control]="formulario.controls.importe" />
</label>
@if (estado().errores) {
<p class="aviso">El formulario tiene errores</p>
}
<div class="fila">
<button type="submit">Enviar</button>
<button type="button" (click)="alternar()">Activar o desactivar valoración</button>
</div>
</form>
<pre id="valor">{{ estado().valor | json }}</pre>
`,
})
export class Encuesta {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly formulario = this.fb.group({
nota: this.fb.control<number | null>(null, [Validators.required, Validators.min(1)]),
importe: this.fb.control<number | null>(1250.5),
});
protected readonly estado = estadoDe(this.formulario);
protected alternar() {
const nota = this.formulario.controls.nota;
if (nota.disabled) {
nota.enable();
} else {
nota.disable();
}
}
protected enviar() {
this.formulario.markAllAsTouched();
}
}

Figura 11. El control propio se comporta como un <input>: valor, validación y touched pasan por el FormControl.
Controles con señales, sin ControlValueAccessor 🆕 v22.0
🆕 Desde Angular 22.0. Las directivas
formControlyformControlNameaceptan un componente convalue = model(), sinControlValueAccessor, yngNoCvadesactiva el adaptador de un elemento. No existe en la LTS (Angular 21), que lanzaNG01203: No value accessor. Si usas la LTS: implementaControlValueAccessor, como en el apartado anterior.
Angular 22 trae un camino más corto. Si un componente tiene un model() llamado value, las directivas de formularios reactivos lo usan como control, sin providers ni registros. Es la misma forma que usa Signal Forms, con la interfaz FormValueControl, así que un mismo control sirve para los dos mundos:
// src/app/formularios/valoracion-senal.ts
import { Component, input, model, output } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'app-valoracion-senal',
template: `
<div role="radiogroup" aria-label="Valoración">
@for (n of [1, 2, 3, 4, 5]; track n) {
<button
type="button"
role="radio"
class="estrella"
[class.activa]="n <= (value() ?? 0)"
[attr.aria-checked]="n === value()"
[attr.aria-label]="n + ' de 5'"
[disabled]="disabled()"
(click)="value.set(n)"
(blur)="touch.emit()"
>★</button>
}
</div>
@if (invalid() && touched()) {
<p class="error">Elige una valoración</p>
}
`,
styles: `
.estrella { background: none; border: none; padding: 0 .1rem; font-size: 1.8rem; color: #cfd8dc; }
.estrella.activa { color: #3949ab; }
`,
})
export class ValoracionSenal implements FormValueControl<number | null> {
readonly value = model<number | null>(null);
readonly touch = output<void>();
readonly disabled = input(false);
readonly invalid = input(false);
readonly touched = input(false);
}
// src/app/formularios/encuesta-senal.ts
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import { ValoracionSenal } from './valoracion-senal';
@Component({
selector: 'app-encuesta-senal',
imports: [ReactiveFormsModule, ValoracionSenal],
template: `
<form [formGroup]="formulario" (ngSubmit)="formulario.markAllAsTouched()">
<app-valoracion-senal formControlName="nota" />
<button type="submit">Enviar</button>
<button type="button" (click)="formulario.reset()">Reiniciar</button>
</form>
<p id="valor">Nota en el modelo: {{ nota() }}</p>
`,
})
export class EncuestaSenal {
protected readonly formulario = inject(NonNullableFormBuilder).group({
nota: [null as number | null, Validators.required],
});
protected readonly nota = toSignal(this.formulario.controls.nota.valueChanges, {
initialValue: null,
});
}
Compara: no hay writeValue, ni registerOnChange, ni provider. Angular:
- escribe en
valuecuando el modelo cambia, y escucha elmodelpara llevar alFormControllo que cambie el usuario, marcándolo comodirty; - llama a
markAsTouched()cuando el componente emitetouch; - rellena las entradas de estado que tú declares:
disabled,invalid,touched,dirty,pending,required,errors.
En una prueba real, con una nota inicial de 3 y Validators.min(5), tras un clic el control pasó a 4 con dirty y touched ciertos, y las entradas invalid y touched recibieron su valor. Un disable() y un setValue(9) posteriores llegaron al componente.
Si tu componente tuviera a la vez un ControlValueAccessor, este tiene prioridad. El atributo ngNoCva en el elemento lo ignora y fuerza el camino por señales, algo útil al migrar poco a poco.
En el módulo 18 verás FormValueControl junto a Signal Forms.
13. Caso completo: la ficha de un empleado
Juntemos todo en una pantalla de edición contra la API: cargar datos, editar, enviar, y tratar la respuesta y sus errores. Es el flujo que repetirás en cualquier aplicación.
Servicios de sesión y de empleados con @Service 🆕 v22.0
🆕 Desde Angular 22.0.
@Service()no existe en la LTS (Angular 21). Si usas la LTS: sustituye@Service()por@Injectable({ providedIn: 'root' })en estos dos servicios; hacen lo mismo.
Las escrituras exigen un token. En el módulo 12 lo añadía un interceptor. Para no depender de él en este módulo, usamos un servicio mínimo, con un usuario de prueba escrito en el código:
// src/app/empresa/sesion.ts
import { HttpClient } from '@angular/common/http';
import { inject, Service } from '@angular/core';
import { map, Observable, of, switchMap, tap } from 'rxjs';
import { API_URL } from './api-url';
/** Solo para los ejemplos: en una aplicación real, el login y un interceptor. */
@Service()
export class Sesion {
private readonly http = inject(HttpClient);
private readonly url = `${inject(API_URL)}/auth/login`;
private token: string | null = null;
private obtenerToken(): Observable<string> {
if (this.token) return of(this.token);
const credenciales = { email: 'luis.martinez@empresa.es', password: 'secreto' };
return this.http.post<{ token: string }>(this.url, credenciales).pipe(
map((respuesta) => respuesta.token),
tap((token) => (this.token = token)),
);
}
autorizar<T>(llamada: (headers: { Authorization: string }) => Observable<T>): Observable<T> {
return this.obtenerToken().pipe(
switchMap((token) => llamada({ Authorization: `Bearer ${token}` })),
);
}
}
// src/app/empresa/empleados-api.ts
import { HttpClient } from '@angular/common/http';
import { inject, Service } from '@angular/core';
import { Observable } from 'rxjs';
import { API_URL } from './api-url';
import { DatosEmpleado, Empleado } from './modelos';
import { Sesion } from './sesion';
@Service()
export class EmpleadosApi {
private readonly http = inject(HttpClient);
private readonly sesion = inject(Sesion);
private readonly url = `${inject(API_URL)}/empleados`;
leer(id: number): Observable<Empleado> {
return this.http.get<Empleado>(`${this.url}/${id}`);
}
actualizar(id: number, datos: DatosEmpleado): Observable<Empleado> {
return this.sesion.autorizar((headers) =>
this.http.put<Empleado>(`${this.url}/${id}`, datos, { headers }),
);
}
}
El componente
// src/app/formularios/ficha-empleado.ts
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, input, numberAttribute, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule } from '@angular/forms';
import { finalize, switchMap } from 'rxjs';
import { EmpleadosApi } from '../empresa/empleados-api';
import { Empleado, ErrorApi } from '../empresa/modelos';
import { ErrorCampo } from './error-campo';
import { estadoDe } from './estado-de';
import { FormularioEmpleado } from './formulario-empleado';
@Component({
selector: 'app-ficha-empleado',
imports: [ReactiveFormsModule, ErrorCampo],
template: `
<h2>Empleado {{ id() }}</h2>
@if (errorCarga(); as texto) {
<p class="error" role="alert">{{ texto }}</p>
}
<form [formGroup]="formulario" (ngSubmit)="guardar()">
<div class="fila">
<label>
Nombre <input formControlName="nombre" />
<app-error-campo [control]="formulario.controls.nombre" />
</label>
<label>
Apellidos <input formControlName="apellidos" />
<app-error-campo [control]="formulario.controls.apellidos" />
</label>
</div>
<label>
Email <input id="email" formControlName="email" />
<app-error-campo [control]="formulario.controls.email" />
</label>
<label>
Oficio <input formControlName="oficio" />
<app-error-campo [control]="formulario.controls.oficio" />
</label>
<label>
Salario <input type="number" formControlName="salario" />
<app-error-campo [control]="formulario.controls.salario" />
</label>
<div class="fila">
<button type="submit" [disabled]="guardando() || !estado().modificado">
{{ guardando() ? 'Guardando...' : 'Guardar cambios' }}
</button>
<button type="button" [disabled]="!estado().modificado" (click)="descartar()">
Descartar
</button>
</div>
@if (exito(); as texto) {
<p class="ok" role="status">{{ texto }}</p>
}
</form>
`,
})
export class FichaEmpleado {
private readonly api = inject(EmpleadosApi);
// Llega de la ruta, con withComponentInputBinding (módulo 10)
readonly id = input.required({ transform: numberAttribute });
protected readonly formulario = inject(FormularioEmpleado).crear();
protected readonly estado = estadoDe(this.formulario);
protected readonly guardando = signal(false);
protected readonly errorCarga = signal<string | null>(null);
protected readonly exito = signal<string | null>(null);
private original = this.formulario.getRawValue();
constructor() {
toObservable(this.id)
.pipe(
switchMap((id) => this.api.leer(id)),
takeUntilDestroyed(),
)
.subscribe({
next: (empleado) => this.mostrar(empleado),
error: () => this.errorCarga.set('No se ha podido cargar el empleado'),
});
}
/** Rellena el formulario y lo deja como "sin cambios". */
private mostrar(empleado: Empleado) {
this.formulario.reset({ ...empleado, telefono: empleado.telefono ?? '' });
this.original = this.formulario.getRawValue();
}
protected descartar() {
this.formulario.reset(this.original);
this.exito.set(null);
}
protected guardar() {
this.exito.set(null);
if (this.formulario.invalid) {
this.formulario.markAllAsTouched();
return;
}
const { salario, ...resto } = this.formulario.getRawValue();
this.guardando.set(true);
this.api
.actualizar(this.id(), { ...resto, salario: salario ?? 0, telefono: resto.telefono || null })
.pipe(finalize(() => this.guardando.set(false)))
.subscribe({
next: (empleado) => {
this.mostrar(empleado);
this.exito.set('Cambios guardados');
},
error: (error: HttpErrorResponse) => this.mostrarError(error),
});
}
private mostrarError(error: HttpErrorResponse) {
const cuerpo = error.error as ErrorApi | null;
if (error.status === 422 && cuerpo?.detalles) {
for (const [campo, texto] of Object.entries(cuerpo.detalles)) {
const control = this.formulario.get(campo);
control?.setErrors({ servidor: texto });
control?.markAsTouched();
}
return;
}
this.errorCarga.set(cuerpo?.mensaje ?? 'Error inesperado al guardar');
}
}

Figura 12. El error 422 de la API se coloca en su campo con setErrors, y ErrorCampo lo muestra como cualquier otro.
Las piezas que importan:
reset(datos)para cargar, nopatchValue.resetdeja el formulariopristineyuntouched. Si cargas conpatchValuey antes el usuario había tocado algo, el formulario seguirádirtyy el botón activo.- Los campos que no están en el formulario se ignoran.
resetrecibe el objeto completo de la API, que traeempIdydepartamento, y toma solo lo que tiene. El teléfono se convierte porque la API puede darnully el control es unstring. getRawValue()al enviar, siempre.telefono: resto.telefono || null: la API guardanull, y el formulario''. Esa conversión entre lo que muestra el formulario y lo que espera la API es parte normal del trabajo.[disabled]="!estado().modificado": se lee con la señal. Es la única manera de que sea fiable aquí, porqueresetno ocurre dentro de un evento de la plantilla.finalizese ejecuta tanto si acaba bien como mal, y evita repetirguardando.set(false).
Errores del servidor en el formulario
La API responde 422 con un objeto detalles, cuyas claves son los nombres de los campos:
{
"status": 422,
"mensaje": "Hay datos no válidos",
"detalles": { "email": "Ese email ya está en uso" }
}
Si el nombre del control coincide con la clave, this.formulario.get(campo) lo encuentra. setErrors añade el error sin pasar por los validadores. Así el error desaparece solo en cuanto el usuario cambia el campo, porque la nueva validación sustituye a errors entero.
💡 Consejo:
setErrorssustituye todos los errores del control. Si necesitas añadirlo a los que había, usasetErrors({ ...control.errors, servidor: texto }). Y si el servidor devuelve un campo que no está en el formulario,getdanull: conviene mostrar ese mensaje en un aviso general.
14. Errores frecuentes y depuración
Estos mensajes y códigos son los reales, obtenidos provocando cada error en Angular 22.2:
| Síntoma | Causa | Arreglo |
|---|---|---|
NG8002: Can't bind to 'formGroup' since it isn't a known property of 'form'. | Falta ReactiveFormsModule en imports del componente | Añádelo |
NG01050: formControlName must be used with a parent formGroup or formArray directive. | formControlName sin [formGroup] por encima | Envuelve el campo en el <form [formGroup]> |
NG01055: Cannot find control with path: 'nombr'. Controls that do exist at this level: nombre. | El nombre no existe en el grupo, o hay una errata | Revisa la clave, incluida la anidación |
NG01203: No value accessor for form control name: 'a'. | El elemento no tiene adaptador, como en un <div>, o tu componente no registra NG_VALUE_ACCESSOR | Regístralo, o declara value = model() en Angular 22 |
NG01052: formGroup expects a FormGroup instance. Please pass one in. | [formGroup] recibe null o undefined | Crea el grupo antes de pintar, o usa @if |
Aviso en consola por usar disabled con un control reactivo | [disabled] en el <input> | Usa control.disable() |
El valor de un select llega como texto | [value] en <option> | Usa [ngValue] |
form.valid es false pero no hay errores | Estado PENDING o DISABLED | Mira form.status |
El mensaje de minlength no aparece | Se busca minLength | La clave va en minúsculas |
La plantilla no refleja un setValue | Nadie avisa a la vista | Lee con una señal (sección 7) |
| Un hijo que recibe el control no se actualiza | OnPush y misma referencia | Escucha events en el hijo |
El control se queda en PENDING | El observable del validador no se completa | Añade first() |
| El validador cambiado no surte efecto | Falta updateValueAndValidity() | Llámalo tras cambiar las reglas |
En Angular 21, el error de un nombre inexistente no tiene código y dice Cannot find control with name: 'nombr'. El aviso de [disabled] dice, en resumen:
It looks like you're using the disabled attribute with a reactive form directive. If you set disabled to true
when you set up this control in your component class, the disabled attribute will actually be set in the DOM for
you. We recommend using this approach to avoid 'changed after checked' errors.
Mirar dentro de un formulario
Durante el desarrollo, un <pre> con el estado vale más que cualquier depurador:
<pre>{{ estado() | json }}</pre>
Para buscar qué hijo tiene errores, cuando formulario.invalid es cierto y no ves por qué, esta función recorre el árbol:
function erroresDe(control: AbstractControl, ruta = 'raíz'): string[] {
const propios = control.errors ? [`${ruta}: ${Object.keys(control.errors).join()}`] : [];
const hijos = control instanceof FormGroup || control instanceof FormArray
? Object.entries(control.controls).flatMap(([clave, hijo]) => erroresDe(hijo as AbstractControl, `${ruta}.${clave}`))
: [];
return [...propios, ...hijos];
}
const pedido = new FormGroup({
clienteId: new FormControl<number | null>(null, Validators.required),
lineas: new FormArray([
new FormGroup({ cantidad: new FormControl(0, Validators.min(1)) }),
]),
});
console.log(erroresDe(pedido).join('\n'));
raíz.clienteId: required
raíz.lineas.0.cantidad: min
También puedes inspeccionar el componente con Angular DevTools, del módulo 01. El módulo 26 enseña a probar formularios.
15. ¿Qué tipo de formulario elijo?
Angular tiene tres sistemas. Los tres son oficiales, y puedes mezclarlos en una misma aplicación, aunque no en un mismo formulario.
| Plantilla (módulo 16) | Reactivos (este módulo) | Signal Forms (módulo 18) | |
|---|---|---|---|
| Dónde vive el modelo | En la plantilla | En la clase, como árbol de controles | En una señal tuya |
| Tipado | Parcial | Completo | Completo |
| Cambios | ngModel | Observables y events | Señales |
| Encaje con zoneless | Bueno en lo básico | Requiere puentes como estadoDe | Natural |
| Formularios dinámicos | Limitado | Muy bueno | Muy bueno |
| Madurez | Años | Años, el más extendido | Estable desde 22; experimental en 21 |
| Cuándo | Formularios de pocos campos | Código existente, y proyectos en la LTS | Proyectos nuevos en Angular 22 |
Los formularios reactivos no están obsoletos ni lo estarán pronto: hay millones de líneas escritas con ellos, y además son la mejor opción estable si te quedas en la LTS. Y si tienes un proyecto reactivo grande, Signal Forms incluye una capa compat para convivir con él, como verás en el módulo siguiente.
16. Ejercicios
Para resolverlos, arranca la API EMPRESA (recursos/empresa-api.zip, node server.mjs). Todas las soluciones se han compilado y ejecutado contra ella. Las soluciones usan ErrorCampo, estadoDe y API_URL, de las secciones anteriores.
Ejercicio 1: Inicio de sesión
Crea un formulario de inicio de sesión con email y password contra POST /api/auth/login. El botón muestra “Entrando…” durante la petición, y si la API responde 401 se muestra su mensaje sin borrar lo escrito. Al terminar bien, muestra el nombre del usuario. Prueba con ana.garcia@empresa.es y secreto.
Ver solución
// src/app/ejercicios/login.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { finalize } from 'rxjs';
import { API_URL } from '../empresa/api-url';
import { ErrorApi } from '../empresa/modelos';
import { ErrorCampo } from '../formularios/error-campo';
interface RespuestaLogin {
token: string;
usuario: { nombre: string; rol: string };
}
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule, ErrorCampo],
template: `
<form [formGroup]="formulario" (ngSubmit)="entrar()">
<label>
Email <input id="email" type="email" formControlName="email" />
<app-error-campo [control]="formulario.controls.email" />
</label>
<label>
Contraseña <input id="clave" type="password" formControlName="password" />
<app-error-campo [control]="formulario.controls.password" />
</label>
<button type="submit" [disabled]="enviando()">
{{ enviando() ? 'Entrando...' : 'Entrar' }}
</button>
@if (error(); as texto) {
<p class="error" role="alert">{{ texto }}</p>
}
@if (usuario(); as nombre) {
<p class="ok" role="status">Hola, {{ nombre }}</p>
}
</form>
`,
})
export class Login {
private readonly http = inject(HttpClient);
private readonly url = `${inject(API_URL)}/auth/login`;
protected readonly formulario = inject(NonNullableFormBuilder).group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
protected readonly enviando = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly usuario = signal<string | null>(null);
protected entrar() {
if (this.formulario.invalid) {
this.formulario.markAllAsTouched();
return;
}
this.enviando.set(true);
this.error.set(null);
this.http.post<RespuestaLogin>(this.url, this.formulario.getRawValue())
.pipe(finalize(() => this.enviando.set(false)))
.subscribe({
next: ({ usuario }) => this.usuario.set(usuario.nombre),
error: (e: HttpErrorResponse) => this.error.set((e.error as ErrorApi).mensaje),
});
}
}
Con una contraseña mala, la API responde Email o contraseña incorrectos, y el formulario conserva lo escrito porque no se llama a reset.
Ejercicio 2: Cambio de contraseña
Crea un formulario con actual, nueva y repetida. La nueva debe tener al menos 8 caracteres y ser distinta de la actual, y las dos últimas deben coincidir. Usa un único validador de grupo que devuelva errores distintos, y enseña el mensaje debajo de repetida. El validador debe poder probarse sin componente.
Ver solución
// src/app/ejercicios/cambio-clave.ts
import { Component, computed, inject } from '@angular/core';
import {
AbstractControl, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, Validators,
} from '@angular/forms';
import { ErrorCampo } from '../formularios/error-campo';
import { estadoDe } from '../formularios/estado-de';
export function clavesCoherentes(grupo: AbstractControl): ValidationErrors | null {
const { actual, nueva, repetida } = grupo.getRawValue() as Record<string, string>;
const errores: ValidationErrors = {};
if (nueva && nueva === actual) errores['igualQueActual'] = true;
if (repetida && nueva !== repetida) errores['noCoinciden'] = true;
return Object.keys(errores).length ? errores : null;
}
@Component({
selector: 'app-cambio-clave',
imports: [ReactiveFormsModule, ErrorCampo],
template: `
<form [formGroup]="formulario" (ngSubmit)="formulario.markAllAsTouched()">
<label>Actual <input id="actual" type="password" formControlName="actual" /></label>
<label>
Nueva <input id="nueva" type="password" formControlName="nueva" />
<app-error-campo [control]="formulario.controls.nueva" />
</label>
<label>
Repetir <input id="repetida" type="password" formControlName="repetida" />
</label>
@for (texto of mensajes(); track texto) {
<p class="error" role="alert">{{ texto }}</p>
}
<button type="submit">Cambiar</button>
</form>
`,
})
export class CambioClave {
protected readonly formulario = inject(NonNullableFormBuilder).group(
{
actual: ['', Validators.required],
nueva: ['', [Validators.required, Validators.minLength(8)]],
repetida: ['', Validators.required],
},
{ validators: clavesCoherentes },
);
private readonly estado = estadoDe(this.formulario);
protected readonly mensajes = computed(() => {
const errores = this.estado().errores;
return [
...(errores?.['igualQueActual'] ? ['La nueva contraseña debe ser distinta de la actual'] : []),
...(errores?.['noCoinciden'] ? ['Las contraseñas no coinciden'] : []),
];
});
}
Al ser una función corriente, clavesCoherentes(new FormGroup({...})) se prueba directamente. El validador de grupo vuelve a ejecutarse con cada cambio de un hijo, así que no hace falta sincronizar nada.
Ejercicio 3: Alta de producto con errores del servidor
Crea el alta de un producto contra POST /api/productos, con nombre, categoriaId (un select cargado de GET /api/categorias), precio y stock. No pongas validadores en el cliente para precio y stock, de modo que un precio de -5 llegue al servidor, y coloca en cada campo los errores 422.
Ver solución
// src/app/ejercicios/alta-producto.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms';
import { map } from 'rxjs';
import { API_URL } from '../empresa/api-url';
import { ErrorApi, Pagina } from '../empresa/modelos';
import { Sesion } from '../empresa/sesion';
import { ErrorCampo } from '../formularios/error-campo';
interface Categoria {
categoriaId: number;
nombre: string;
}
@Component({
selector: 'app-alta-producto',
imports: [ReactiveFormsModule, ErrorCampo],
template: `
<form [formGroup]="formulario" (ngSubmit)="crear()">
<label>
Nombre <input id="nombre" formControlName="nombre" />
<app-error-campo [control]="formulario.controls.nombre" />
</label>
<label>
Categoría
<select id="categoria" formControlName="categoriaId">
<option [ngValue]="null">Elige una</option>
@for (c of categorias(); track c.categoriaId) {
<option [ngValue]="c.categoriaId">{{ c.nombre }}</option>
}
</select>
<app-error-campo [control]="formulario.controls.categoriaId" />
</label>
<label>
Precio <input id="precio" type="number" formControlName="precio" />
<app-error-campo [control]="formulario.controls.precio" />
</label>
<label>
Stock <input id="stock" type="number" formControlName="stock" />
<app-error-campo [control]="formulario.controls.stock" />
</label>
<button type="submit">Crear</button>
@if (creado(); as texto) {
<p class="ok" role="status">{{ texto }}</p>
}
</form>
`,
})
export class AltaProducto {
private readonly fb = inject(NonNullableFormBuilder);
private readonly http = inject(HttpClient);
private readonly sesion = inject(Sesion);
private readonly api = inject(API_URL);
protected readonly categorias = toSignal(
this.http.get<Pagina<Categoria>>(`${this.api}/categorias`).pipe(map((p) => p.contenido)),
{ initialValue: [] },
);
protected readonly creado = signal<string | null>(null);
protected readonly formulario = this.fb.group({
nombre: [''],
categoriaId: this.fb.control<number | null>(null),
precio: this.fb.control<number | null>(null),
stock: this.fb.control<number | null>(0),
});
protected crear() {
this.creado.set(null);
this.sesion.autorizar((headers) =>
this.http.post<{ productoId: number; nombre: string }>(
`${this.api}/productos`, this.formulario.getRawValue(), { headers },
),
).subscribe({
next: ({ productoId, nombre }) => {
this.creado.set(`Producto ${productoId}: ${nombre}`);
this.formulario.reset();
},
error: (e: HttpErrorResponse) => {
const detalles = (e.error as ErrorApi).detalles ?? {};
for (const [campo, texto] of Object.entries(detalles)) {
const control = this.formulario.get(campo);
control?.setErrors({ servidor: texto });
control?.markAsTouched();
}
},
});
}
}
Si envías el formulario vacío con precio -5, la API contesta con estos errores, y cada uno va a su campo:
El nombre es obligatorio
La categoría no existe
El precio no puede ser negativo
Ejercicio 4: Nombre de departamento libre
Crea un campo para el nombre de un departamento nuevo, con un validador asíncrono que consulte GET /api/departamentos?q=... y marque nombreOcupado si ya existe, sin distinguir mayúsculas ni tildes. Debe ejecutarse al salir del campo, y comprobar que un DIRECCIÓN escrito como direccion también se detecta.
Ver solución
// src/app/ejercicios/nombre-departamento.ts
import { HttpClient } from '@angular/common/http';
import { Component, computed, inject } from '@angular/core';
import { AsyncValidatorFn, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { catchError, map, of } from 'rxjs';
import { API_URL } from '../empresa/api-url';
import { Pagina } from '../empresa/modelos';
import { estadoDe } from '../formularios/estado-de';
const normalizar = (texto: string) =>
texto.normalize('NFD').replace(/\p{M}/gu, '').trim().toLowerCase();
function nombreLibre(): AsyncValidatorFn {
const http = inject(HttpClient);
const url = `${inject(API_URL)}/departamentos`;
return (control) => {
const buscado = normalizar(control.value as string);
return http.get<Pagina<{ nombre: string }>>(url, { params: { q: buscado } }).pipe(
map(({ contenido }) =>
contenido.some((d) => normalizar(d.nombre) === buscado) ? { nombreOcupado: true } : null,
),
catchError(() => of(null)),
);
};
}
@Component({
selector: 'app-nombre-departamento',
imports: [ReactiveFormsModule],
template: `
<label>Nombre <input id="nombre" [formControl]="nombre" /></label>
@switch (estado().estado) {
@case ('PENDING') { <p class="aviso">Comprobando...</p> }
@case ('VALID') { <p class="ok" id="libre">Nombre libre</p> }
@case ('INVALID') {
@if (estado().errores?.['nombreOcupado']) {
<p class="error" id="ocupado">Ya existe un departamento con ese nombre</p>
}
}
}
`,
})
export class NombreDepartamento {
protected readonly nombre = new FormControl('', {
nonNullable: true,
validators: [Validators.required],
asyncValidators: [nombreLibre()],
updateOn: 'blur',
});
protected readonly estado = estadoDe(this.nombre);
}
Con updateOn: 'blur' no hace falta debounce, porque el validador solo se lanza una vez por salida del campo. El campo sin tocar sale como INVALID por el required, y el @if interior evita enseñar un mensaje equivocado.
Ejercicio 5: Comisión obligatoria para los vendedores
Un empleado con el oficio VENDEDOR debe tener comisión, y el resto no. Resuélvelo sin valueChanges ni addValidators, con un validador de grupo, y muestra el mensaje junto al campo.
Ver solución
// src/app/ejercicios/comision.ts
import { Component, inject } from '@angular/core';
import {
AbstractControl, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors,
} from '@angular/forms';
import { estadoDe } from '../formularios/estado-de';
function comisionDeVendedor(grupo: AbstractControl): ValidationErrors | null {
const { oficio, comision } = grupo.getRawValue() as { oficio: string; comision: number | null };
return oficio === 'VENDEDOR' && comision === null ? { falta: 'comision' } : null;
}
@Component({
selector: 'app-comision',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="formulario">
<label>
Oficio
<select id="oficio" formControlName="oficio">
<option value="ANALISTA">ANALISTA</option>
<option value="VENDEDOR">VENDEDOR</option>
</select>
</label>
<label>Comisión <input id="comision" type="number" formControlName="comision" /></label>
@if (estado().errores?.['falta']) {
<p class="error" id="falta" role="alert">Un vendedor debe tener comisión</p>
}
<p id="estado">{{ estado().estado }}</p>
</form>
`,
})
export class Comision {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly formulario = this.fb.group(
{
oficio: ['ANALISTA'],
comision: this.fb.control<number | null>(null),
},
{ validators: comisionDeVendedor },
);
protected readonly estado = estadoDe(this.formulario);
}
Comparado con la sección 8, no hay suscripción, ni referencias de validadores, ni updateValueAndValidity. El grupo revalida solo. El error vive en el grupo, y el mensaje se dibuja junto al campo. No lo fuerces con setErrors desde el validador: un validador debe ser una función pura.
Ejercicio 6: Equipo de un proyecto
Crea un formulario con un FormArray de asignaciones, cada una con empId (select con empleados de GET /api/empleados) y horas. El array no debe repetir empleado (reutiliza sinRepetidos), y un validador de array limitará la suma a 2000 horas. Muestra la suma en vivo.
Ver solución
// src/app/ejercicios/equipo-proyecto.ts
import { HttpClient } from '@angular/common/http';
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
AbstractControl, FormArray, FormControl, FormGroup, NonNullableFormBuilder,
ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators,
} from '@angular/forms';
import { map } from 'rxjs';
import { API_URL } from '../empresa/api-url';
import { Empleado, Pagina } from '../empresa/modelos';
import { estadoDe } from '../formularios/estado-de';
import { sinRepetidos } from '../formularios/validadores';
interface GrupoAsignacion {
empId: FormControl<number | null>;
horas: FormControl<number>;
}
function sumaMaxima(limite: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const filas = (control as FormArray).getRawValue() as { horas: number }[];
const suma = filas.reduce((total, fila) => total + (fila.horas ?? 0), 0);
return suma > limite ? { demasiadasHoras: { limite, suma } } : null;
};
}
@Component({
selector: 'app-equipo-proyecto',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="formulario">
<div formArrayName="asignaciones">
@for (fila of asignaciones.controls; track fila; let i = $index) {
<div class="fila" [formGroupName]="i">
<select formControlName="empId" aria-label="Empleado">
<option [ngValue]="null">Elige</option>
@for (e of empleados(); track e.empId) {
<option [ngValue]="e.empId">{{ e.nombre }} {{ e.apellidos }}</option>
}
</select>
<input type="number" formControlName="horas" aria-label="Horas" />
<button type="button" (click)="asignaciones.removeAt(i)">Quitar</button>
</div>
}
</div>
<button type="button" id="anadir" (click)="anadir()">Añadir persona</button>
<p>Suma: <strong id="suma">{{ suma() }}</strong> horas</p>
@if (estado().errores?.['demasiadasHoras']; as error) {
<p class="error" id="exceso">Máximo {{ error.limite }} horas: llevas {{ error.suma }}</p>
}
@if (estado().errores?.['repetido']) {
<p class="error" id="repe">Hay un empleado repetido</p>
}
</form>
`,
})
export class EquipoProyecto {
private readonly fb = inject(NonNullableFormBuilder);
private readonly http = inject(HttpClient);
private readonly api = inject(API_URL);
protected readonly empleados = toSignal(
this.http.get<Pagina<Empleado>>(`${this.api}/empleados`, { params: { tamano: 100 } })
.pipe(map((p) => p.contenido)),
{ initialValue: [] },
);
protected readonly formulario = this.fb.group({
asignaciones: this.fb.array<FormGroup<GrupoAsignacion>>([], {
validators: [sinRepetidos('empId'), sumaMaxima(2000)],
}),
});
protected readonly asignaciones = this.formulario.controls.asignaciones;
protected readonly estado = estadoDe(this.asignaciones);
protected readonly suma = computed(() =>
this.estado().valor.reduce((total, fila) => total + fila.horas, 0),
);
protected anadir() {
this.asignaciones.push(
this.fb.group<GrupoAsignacion>({
empId: this.fb.control<number | null>(null, Validators.required),
horas: this.fb.control(100, [Validators.min(1)]),
}),
);
}
}
estadoDe también funciona con un FormArray: la función es genérica porque opera sobre AbstractControl. Así suma sale de una sola señal, sin suscribirse a nada más.
Ejercicio 7: Selector de cantidad
Crea un control propio <app-cantidad> con botones - y +, y límites min y max por input(). Debe funcionar con formControlName, incluidos disabled y touched. Pruébalo en un formulario con un botón que lo desactive.
Ver solución
// src/app/ejercicios/cantidad.ts
import { Component, forwardRef, inject, input, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
ControlValueAccessor, NG_VALUE_ACCESSOR, NonNullableFormBuilder, ReactiveFormsModule,
} from '@angular/forms';
@Component({
selector: 'app-cantidad',
template: `
<span class="fila">
<button type="button" id="menos" [disabled]="desactivado() || valor() <= min()"
(click)="sumar(-1)" (blur)="alTocar()">-</button>
<output id="numero">{{ valor() }}</output>
<button type="button" id="mas" [disabled]="desactivado() || valor() >= max()"
(click)="sumar(1)" (blur)="alTocar()">+</button>
</span>
`,
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => Cantidad), multi: true },
],
})
export class Cantidad implements ControlValueAccessor {
readonly min = input(1);
readonly max = input(10);
protected readonly valor = signal(1);
protected readonly desactivado = signal(false);
private alCambiar: (valor: number) => void = () => {};
protected alTocar: () => void = () => {};
writeValue(valor: number | null): void {
this.valor.set(valor ?? this.min());
}
registerOnChange(fn: (valor: number) => void): void {
this.alCambiar = fn;
}
registerOnTouched(fn: () => void): void {
this.alTocar = fn;
}
setDisabledState(desactivado: boolean): void {
this.desactivado.set(desactivado);
}
protected sumar(paso: number) {
this.valor.update((v) => v + paso);
this.alCambiar(this.valor());
}
}
@Component({
selector: 'app-prueba-cantidad',
imports: [ReactiveFormsModule, Cantidad],
template: `
<form [formGroup]="formulario">
<app-cantidad formControlName="unidades" [max]="3" />
<button type="button" id="alternar" (click)="alternar()">Activar o desactivar</button>
</form>
<p id="modelo">Modelo: {{ valor() }}</p>
`,
})
export class PruebaCantidad {
protected readonly formulario = inject(NonNullableFormBuilder).group({ unidades: [2] });
protected readonly valor = toSignal(this.formulario.controls.unidades.valueChanges, {
initialValue: 2,
});
protected alternar() {
const control = this.formulario.controls.unidades;
if (control.disabled) {
control.enable();
} else {
control.disable();
}
}
}
Ejercicio 8: Aviso de cambios sin guardar
Escribe un guard CanDeactivateFn que pregunte con confirm antes de salir de un formulario modificado. Debe valer para cualquier componente que exponga un método tieneCambios(). Aplícalo a una ruta y compruébalo.
Ver solución
// src/app/ejercicios/sin-guardar.ts
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms';
import { CanDeactivateFn, RouterLink } from '@angular/router';
export interface ConCambios {
tieneCambios(): boolean;
}
export const avisarCambios: CanDeactivateFn<ConCambios> = (componente) =>
!componente.tieneCambios() || confirm('Tienes cambios sin guardar. ¿Salir de todos modos?');
@Component({
selector: 'app-sin-guardar',
imports: [ReactiveFormsModule, RouterLink],
template: `
<form [formGroup]="formulario">
<label>Oficio <input id="oficio" formControlName="oficio" /></label>
</form>
<a id="salir" routerLink="/">Salir</a>
`,
})
export class SinGuardar implements ConCambios {
protected readonly formulario = inject(NonNullableFormBuilder).group({ oficio: ['ANALISTA'] });
tieneCambios(): boolean {
return this.formulario.dirty;
}
}
La ruta queda así:
{
path: 'sin-guardar',
loadComponent: () => import('./ejercicios/sin-guardar').then((m) => m.SinGuardar),
canDeactivate: [avisarCambios],
},
dirty funciona bien aquí porque el guard lo lee en el momento, sin pasar por la plantilla. Tras guardar, un reset(datos) lo devuelve a falso. Tienes más sobre los guards en el módulo 11.
Ejercicio 9 (propuesto): Campos que aparecen según otros
Amplía CampoDinamico con una propiedad visibleSi: { clave: string; valor: ValorCampo }. Un campo invisible no se dibuja, se desactiva (para no validarse) y sale de value. Empieza con un tipo en un select que muestre un campo nif solo para “empresa”.
Ejercicio 10 (propuesto): Permisos con FormRecord
Carga los departamentos de GET /api/departamentos y crea un FormRecord<FormControl<boolean>> con una casilla por departamento. Añade un botón “Marcar todos” que use patchValue, y un validador que exija al menos una casilla marcada. Muestra un resumen con las claves activas usando una señal.
Ejercicio 11 (propuesto): Migrar a señales
Convierte tu <app-cantidad> del ejercicio 7 en un control con value = model() (solo en Angular 22). Comprueba que disabled y touch se comportan igual, y escribe cuántas líneas te has ahorrado. Después usa ngNoCva en un elemento que tenga los dos mecanismos para ver cuál se aplica.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Directivas | ReactiveFormsModule | Se importa en cada componente que las use |
| Campo | FormControl, [formControl], formControlName | El modelo vive en la clase, y nonNullable evita los null |
| Grupo | FormGroup, [formGroup], formGroupName | El estado sube desde los hijos hasta la raíz |
| Lista | FormArray, formArrayName, [formArray] | @for con track control, y required para exigir elementos |
| Claves libres | FormRecord | Grupo de claves conocidas en ejecución |
| Atajos | NonNullableFormBuilder, fb.control<T>() | Menos código y tipos limpios |
| Enviar datos | getRawValue() | Incluye los desactivados, y su tipo es completo |
| Cambiar datos | setValue, patchValue, reset | reset(datos) deja el formulario sin cambios |
| Estados | status, touched, dirty, pending | El usuario pone touched y dirty; el código, no |
| Mensajes | markAllAsTouched, hasError, getError | Muestra los errores con touched, y las claves en minúsculas |
| Vista | control.events, toSignal | Los controles no son señales: conéctalos con un puente |
| Validador | ValidatorFn, Validators.* | Función pura que da por bueno lo vacío |
| Reglas variables | addValidators, updateValueAndValidity | Hay que revalidar, y se quita por referencia |
| Asíncrono | AsyncValidatorFn, timer | Solo corre si pasan los síncronos, y debe completarse |
| Servidor | setErrors, error.detalles | El error se va al cambiar el campo |
| Dinámicos | computed, fb.record, @switch | Una lista describe los campos y los controles |
| Control propio | ControlValueAccessor, NG_VALUE_ACCESSOR | Estado en señales y null tratado en writeValue |
| Validación propia | Validator, NG_VALIDATORS | El control sabe si su texto es válido |
| Control con señales 🆕 v22.0 | value = model(), touch, ngNoCva | En 21 sigue siendo necesario el adaptador |