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.
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.
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 tipadoUn 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:
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).
nonNullable: true, por qué importaSin 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 distintasValidators.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)].
<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.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.
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.
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.
// 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],});@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>}<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).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.| 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.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:
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.