Ir al contenido

Formularios Reactivos

Hasta ahora todos sus componentes solo leían datos. Un formulario es la primera vez que un componente captura algo que escribe el usuario, lo valida, y solo entonces lo envía. Angular necesita una forma de representar ese estado (lo que el usuario ha escrito, si es válido, si cambió) como algo que el código pueda inspeccionar.

Dos formas de construir formularios en Angular

Sección titulada «Dos formas de construir formularios en Angular»

Template-driven

El estado vive principalmente en el HTML, con ngModel. Es rápido para formularios muy simples, pero el tipo de los datos es difícil de rastrear desde el componente.

Reactive (el que usa este curso)

El estado vive en el componente, como un objeto TypeScript tipado. El HTML solo se conecta a ese objeto, no lo define.

FormGroup y FormControl: el formulario como objeto tipado

Sección titulada «FormGroup y FormControl: el formulario como objeto tipado»

Un FormGroup agrupa varios FormControl, uno por cada campo. El tipo genérico de FormGroup<{ ... }> declara la forma completa del formulario, igual que una interface declara la forma de un objeto:

artist-create.ts
artistaForm: FormGroup<{
nombre: FormControl<string>;
paisOrigen: FormControl<string>;
}> = new FormGroup({
nombre: new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required],
}),
paisOrigen: new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required],
}),
});

Cada FormControl recibe dos cosas: un valor inicial ('') y un objeto de opciones (nonNullable, validators).

Sin nonNullable: true, cada FormControl<string> sería en realidad FormControl<string | null>: Angular permite que un control quede en null (por ejemplo, al hacer .reset() sin un valor de reinicio explícito), y usted tendría que verificar null cada vez que lea el valor.

Validators: validez y tipo son cosas distintas

Sección titulada «Validators: validez y tipo son cosas distintas»

Validators.required marca el control como inválido mientras esté vacío, pero eso no cambia su tipo: sigue siendo string, nunca undefined ni null. Validez (“¿se puede enviar?”) y tipo (“¿qué forma tiene el valor?”) son dos preguntas distintas, y Validators solo responde la primera.

Validador Qué exige
Validators.required El control no puede quedar vacío.
Validators.minLength(n) Al menos n caracteres.
Validators.maxLength(n) A lo sumo n caracteres.
Validators.email El texto tiene forma de correo electrónico.
Validators.pattern(regex) El texto cumple una expresión regular.

Un control puede recibir varios validadores a la vez, como un arreglo: validators: [Validators.required, Validators.minLength(3)].

artist-create.html
<form [formGroup]="artistaForm" (ngSubmit)="guardar()">
<input formControlName="nombre" />
<input formControlName="paisOrigen" />
<button type="submit" [disabled]="artistaForm.invalid">
Crear artista
</button>
<button type="button" (click)="cancelar()">
Cancelar
</button>
</form>
  • [formGroup]="artistaForm" conecta todo el <form> con el objeto artistaForm del componente.
  • formControlName="nombre" conecta un <input> puntual con el FormControl correspondiente dentro del grupo.
  • (ngSubmit)="guardar()" dispara guardar() cuando el formulario se envía, ya sea con Enter o con un clic en un botón type="submit" dentro del <form>.
  • [disabled]="artistaForm.invalid" deshabilita el botón mientras algún validador falle.

Leer los datos: getRawValue() contra value()

Sección titulada «Leer los datos: getRawValue() contra value()»

artistaForm.value existe, pero TypeScript lo tipa como Partial del objeto del formulario (todas las propiedades opcionales), porque un control podría estar deshabilitado y quedar fuera de value. artistaForm.getRawValue(), en cambio, siempre devuelve todas las propiedades, con el tipo completo que declaró en el FormGroup.

artist-create.ts
guardar(): void {
if (this.artistaForm.invalid) return;
const datos = this.artistaForm.getRawValue();
// datos: { nombre: string; paisOrigen: string }
// tipado completo, sin Partial, gracias a nonNullable + getRawValue()
}

artistaForm.reset() devuelve cada control a su valor inicial: el que declaró al crear el FormControl ('' en los ejemplos de arriba). Gracias a nonNullable: true, ese valor inicial es siempre del tipo declarado, nunca null.

artist-create.ts
cancelar(): void {
this.artistaForm.reset();
}

Hasta aquí todos los campos eran <input> de texto. Un formulario de asociación, por ejemplo agregar un álbum a un artista, necesita otra cosa: que la persona elija entre opciones que ya existen en el back. Esas opciones no se escriben en la plantilla. Llegan de un GET, y el formulario tiene que comportarse bien mientras llegan, si falla la carga o si no hay ninguna.

artist-add-album.ts
// Lo expone el servicio del módulo de álbumes (ver httpResource)
albumes = inject(AlbumService).albumesResource;
albumId: FormControl<number | null> = new FormControl<number | null>(null, {
validators: [Validators.required],
});
artist-add-album.html
@if (albumes.isLoading()) {
<p>Cargando álbumes...</p>
} @else if (albumes.error()) {
<p>No se pudieron cargar los álbumes.</p>
} @else if (albumes.hasValue() && albumes.value().length === 0) {
<p>No hay álbumes para elegir.</p>
} @else {
<select [formControl]="albumId">
<option [ngValue]="null">Elige un álbum</option>
@for (album of albumes.value(); track album.id) {
<option [ngValue]="album.id">{{ album.nombre }}</option>
}
</select>
}
  • Cuatro estados, no uno. Un <select> sin opciones se ve igual si la carga está en curso, si falló o si el back no tiene nada. Mostrar cada caso por separado le dice a la persona qué pasó. La rama de error pregunta por error() y no lee value(), que lanza una excepción cuando el recurso falló (ver Cliente HTTP con httpResource).
  • [ngValue] en cada <option>. Con [ngValue]="album.id" el control recibe el id tal como es, un number. Con [value] el control recibe un string (ver el aviso de abajo).
  • La primera opción vale null. <option [ngValue]="null">Elige un álbum</option> representa “todavía no se eligió nada”. Como Validators.required considera inválido un null, el botón de enviar queda deshabilitado hasta que se elija un álbum.
  • Aquí | null es intencional. En la sección de nonNullable se evitaba, porque un campo de texto vacío es ''. Un selector sin elección no tiene un valor natural del tipo del id, y null es la forma honesta de decirlo. Por eso el tipo es FormControl<number | null>, sin nonNullable.
  • Un valor puesto antes de que lleguen las opciones se conserva. Si el control ya tiene un id (por ejemplo, para editar una asociación existente), el selector lo muestra en cuanto las opciones llegan.

Antes de usar el valor hay que descartar el null, y TypeScript obliga a hacerlo:

artist-add-album.ts
asociar(): void {
const id = this.albumId.value;
if (id === null) return;
// desde aquí, id es number
}

El tutorial de asociación usa este selector para asociar un álbum a un artista.

1. ¿Por qué este curso usa formularios reactivos en vez de template-driven?
2. Sin nonNullable: true, ¿qué tipo tiene en realidad un FormControl<string>?
3. Un control tiene Validators.required y hoy está vacío. ¿Qué tipo tiene su valor en ese momento?
4. ¿Por qué el botón "Cancelar" dentro de un <form> necesita type="button"?
5. ¿Qué diferencia a artistaForm.getRawValue() de artistaForm.value?
6. ¿Cuándo se dispara (ngSubmit)?
7. Con <option [value]="album.id">, ¿qué tipo tiene el valor del control al elegir el álbum 12?
8. ¿Para qué sirve <option [ngValue]="null">Elige un álbum</option> junto con Validators.required?
9. ¿Por qué conviene distinguir cargando, error, lista vacía y con opciones, en vez de mostrar siempre el <select>?