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, FormArray y FormRecord
  • Reducir el código repetido con FormBuilder y NonNullableFormBuilder
  • Aprovechar el tipado: distinguir value de getRawValue() y saber cuándo aparece null o undefined
  • Enseñar errores en el momento adecuado usando los estados touched, dirty, pending y disabled
  • 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.value es un number | 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

Árbol de controles del formulario de un pedido: un FormGroup raíz con un FormControl clienteId y un FormArray lineas, que contiene varios FormGroup con productoId y cantidad

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…

Flujo de datos entre el input, el ControlValueAccessor y el FormControl: de la vista al modelo con el evento input y onChange, y del modelo a la vista con setValue y writeValue

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 input llega al adaptador, el FormControl ya 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');
  }
}

Formulario con un campo Nombre que contiene el texto Marta, el párrafo Valor en el modelo: Marta y los botones Poner Luis y Restablecer

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: true en todos los campos de texto. Deja null para lo que de verdad puede no tener valor, como un salario aún sin escribir o un select sin 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 en valueChanges. 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:

  1. (ngSubmit) y no (submit). La directiva [formGroup] escucha el submit nativo, llama a preventDefault() para que el navegador no recargue la página, marca el formulario como enviado y emite ngSubmit.
  2. 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ía ngSubmit cada vez.
  3. [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 disabled ni [disabled] en un <input> con formControlName. El estado lo manda el modelo: se crea con new FormControl({ value: '', disabled: true }) y se cambia con disable() y enable(). 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. Los null que 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.email y Validators.pattern dan por bueno el campo vacío, y minLength también. Un campo opcional con formato solo se valida si tiene algo. Si además es obligatorio, añade Validators.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);
  }
}

Dos formularios tras cargar datos. En el de la izquierda el input dice Marta pero el párrafo sigue diciendo Ana. En el de la derecha el input y el párrafo dicen Marta

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 AbstractControl por input() no se refresca, porque la referencia del control no cambia. En la LTS (Angular 21) el valor por defecto es Eager y 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}`);
  }
}

Alta de empleado tras pulsar Guardar con datos incorrectos: el nombre es obligatorio, el email no es válido, el teléfono no tiene el formato correcto y el salario mínimo es 1

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 rangoSalarial tambié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.

Ciclo de validación: primero se ejecutan los validadores síncronos; si fallan, el estado es INVALID y los asíncronos no se lanzan; si pasan, el estado es PENDING hasta que responde el asíncrono, que termina en VALID o INVALID

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:

  • inject fuera 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í inject fallaría. Por eso se capturan http y url antes.
  • timer(400) es el debounce. Cada vez que cambia el valor, Angular cancela la suscripción al validador anterior y crea otra. El timer del intento anterior muere antes de disparar, así que la petición solo sale cuando el usuario lleva 400 ms sin escribir. Un debounceTime no 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.get se completa solo, pero un observable que emite y no termina, como un valueChanges, deja el control en PENDING para siempre. Lo hemos comprobado: ese mismo observable sale PENDING dentro de un array y VALID como validador suelto. No dependas de esa diferencia y añade first().

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();
  }
}

Formulario con el email ana.garcia arroba empresa punto es, el aviso Comprobando si está libre y el estado PENDING

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

El mismo formulario tras la respuesta: el mensaje Ese email ya lo usa otro empleado y el estado INVALID

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:

  1. Mientras el control está en PENDING, valid e invalid son ambos falsos. Si escribes if (formulario.invalid) return;, el formulario se puede enviar mientras se comprueba. Por eso el botón se desactiva con comprobando(), o bien compruebas formulario.valid.
  2. Sin touched no se ve el error. Si el usuario no ha salido del campo, ErrorCampo no muestra nada. Es lo que quieres en la mayoría de los casos.
  3. Un setValue también lo lanza. Al rellenar un formulario de edición con datos existentes se dispara el validador. Por eso existe idPropio: para que tu propio email no cuente como ocupado.
  4. 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, usa track control, el objeto, y no track $index. Si quitas el teléfono 1 con track $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);
      },
    });
  }
}

Formulario de pedido con el cliente Talleres Martín, dos líneas de producto con sus subtotales en euros y un total de 1.837,80 euros

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) y requireSync. Convierten valueChanges en una señal que ya tiene valor inicial, sin escribir el valor a mano. requireSync funciona porque startWith emite de inmediato.
  • total deriva del formulario. No se guarda en ningún sitio: el total no es un dato, se calcula. Es el patrón de siempre con computed.
  • [ngValue] y no [value]. value convierte a texto: el FormControl recibiría "1001" en vez de 1001, y la API lo rechazaría. [ngValue] conserva el número y el null.
  • this.lineas.clear() antes de reset(). reset deja 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 de productoId y cantidad en 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 lineas es un FormArray, puedes aplicarle validadores propios. sinRepetidos recorre sus controles, como cualquier array. El error queda en el array, y por eso se lee con estadoLineas, 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));
  }
}

Formulario dinámico del tipo Empresa, con razón social, NIF con error de formato, límite de crédito con 5000 y una lista de provincia

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 ser undefined si 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: true porque el token admite varios adaptadores, y Angular elige el mejor: el tuyo gana a los integrados.
  • forwardRef porque en el decorador la clase Valoracion aún no está definida.
  • useExisting para que el adaptador sea la propia instancia del componente, y no una copia.
  • Los estados son señales. writeValue lo 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.
  • elegir escribe y avisa. Si te olvidas de alCambiar, verás la estrella marcada, pero el formulario no se enterará.
  • alTocar en blur. Sin ella, touched nunca será cierto y los errores no aparecerán.

⚠️ Importante: writeValue puede recibir null (por ejemplo, tras reset()), aunque tu control no lo espere. Trátalo siempre, como hace valor ?? 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();
  }
}

Encuesta con cuatro de cinco estrellas marcadas, el campo de importe con el texto 12x y el mensaje de error de importe no válido, y debajo el valor del formulario en JSON con nota 4 e importe null

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 formControl y formControlName aceptan un componente con value = model(), sin ControlValueAccessor, y ngNoCva desactiva el adaptador de un elemento. No existe en la LTS (Angular 21), que lanza NG01203: No value accessor. Si usas la LTS: implementa ControlValueAccessor, 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 value cuando el modelo cambia, y escucha el model para llevar al FormControl lo que cambie el usuario, marcándolo como dirty;
  • llama a markAsTouched() cuando el componente emite touch;
  • 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');
  }
}

Ficha del empleado 110 con el email ana.garcia arroba empresa punto es, marcado en rojo, y el mensaje Ese email ya está en uso devuelto por la API

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, no patchValue. reset deja el formulario pristine y untouched. Si cargas con patchValue y antes el usuario había tocado algo, el formulario seguirá dirty y el botón activo.
  • Los campos que no están en el formulario se ignoran. reset recibe el objeto completo de la API, que trae empId y departamento, y toma solo lo que tiene. El teléfono se convierte porque la API puede dar null y el control es un string.
  • getRawValue() al enviar, siempre.
  • telefono: resto.telefono || null: la API guarda null, 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í, porque reset no ocurre dentro de un evento de la plantilla.
  • finalize se ejecuta tanto si acaba bien como mal, y evita repetir guardando.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: setErrors sustituye todos los errores del control. Si necesitas añadirlo a los que había, usa setErrors({ ...control.errors, servidor: texto }). Y si el servidor devuelve un campo que no está en el formulario, get da null: 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