Formularios: reactive, tipados y Signal Forms
Formularios: reactive, tipados y Signal Forms
Los formularios son donde más código de baja calidad se acumula en una app Angular: any que se cuela por todos lados, la misma regla de validación escrita dos veces (una en el template y otra en la clase), suscripciones a valueChanges que nunca se limpian y formularios de cientos de controles que recalculan validez con cada tecla. Este capítulo ataca esa deuda con criterio: cuándo usar reactive forms tipados, cuándo la nueva API de Signal Forms (estable en Angular 22), cómo estructurar la validación para que viva en un solo lugar y cómo hacer que un formulario grande no cueste rendimiento.
Damos por sabido cómo se arma un formulario básico. El foco está en las decisiones que escalan.
Dos modelos, una regla para elegir
Angular 22 te da tres formas de manejar formularios. La regla de decisión es corta:
- Template-driven (
ngModel): solo para casos triviales (un buscador, un toggle suelto). No escala a validación compleja ni a tests limpios porque la fuente de verdad vive en el DOM, no en tu clase. - Reactive forms tipados: el caballo de batalla probado. Fuente de verdad en la clase, tipos estrictos, integración madura con RxJS y librerías del ecosistema.
- Signal Forms: el modelo nuevo, basado en signals. Estado y validación reactivos por construcción, sin
valueChanges, alineado con el resto del Angular moderno (capítulo Signals y estado reactivo).
flowchart TD
A[¿Formulario?] --> B{¿Trivial y sin<br/>validación compleja?}
B -->|Sí| C[Template-driven<br/>ngModel]
B -->|No| D{¿Proyecto ya<br/>signal-first?}
D -->|Sí, y tolerás<br/>API nueva| E[Signal Forms]
D -->|Necesitás madurez<br/>y ecosistema RxJS| F[Reactive forms tipados]
E --> G[Estado reactivo<br/>sin valueChanges]
F --> H[Fuente de verdad<br/>en la clase, tipada]
Reactive forms tipados
Desde Angular 14 los reactive forms son tipados por defecto. El problema es que muchos proyectos arrastran patrones que rompen ese tipado sin darse cuenta.
El tipado se infiere del valor inicial
Un FormControl infiere su tipo del valor con el que lo inicializás, y por defecto incluye null porque reset() vuelve a null:
const email = new FormControl('[email protected]');
// Tipo inferido: FormControl<string | null>
Ese | null se propaga a todo el value del grupo y te obliga a chequear null en cada lectura. Para valores que nunca deberían ser null, usá la opción nonNullable: hace que reset() restaure el valor inicial en lugar de null.
const email = new FormControl('[email protected]', { nonNullable: true });
// Tipo inferido: FormControl<string>
email.reset(); // vuelve a '[email protected]', no a null
FormBuilder tipado y NonNullableFormBuilder
Escribir { nonNullable: true } en cada control es ruido. NonNullableFormBuilder (accesible como fb.nonNullable) lo aplica a todo el grupo:
import { NonNullableFormBuilder } from '@angular/forms';
import { inject } from '@angular/core';
export class RegistroComponent {
private readonly fb = inject(NonNullableFormBuilder);
readonly form = this.fb.group({
email: this.fb.control('', { validators: [Validators.required, Validators.email] }),
edad: this.fb.control(0, { validators: [Validators.min(18)] }),
});
// form.value → { email: string; edad: number } (sin | null)
}
Fijate que inyectamos con inject(), no por constructor (ver capítulo Inyección de dependencias).
Tipar controles opcionales y grupos dinámicos
Si un control puede no existir (se agrega o quita en runtime), no lo modeles con any: declaralo opcional en una interfaz explícita.
interface PerfilForm {
nombre: FormControl<string>;
telefono?: FormControl<string>; // puede agregarse/quitarse
}
const form = new FormGroup<PerfilForm>({
nombre: new FormControl('', { nonNullable: true }),
});
form.addControl('telefono', new FormControl('', { nonNullable: true }));
form.removeControl('telefono'); // type-safe: solo permite claves opcionales
Para grupos con claves desconocidas en tiempo de compilación (por ejemplo, un diccionario de direcciones cuyo conjunto de claves llega del servidor), usá FormRecord en vez de forzar tipos:
const direcciones = new FormRecord<FormControl<string>>({});
direcciones.addControl('casa', new FormControl('Av. Siempre Viva 742', { nonNullable: true }));
Antipatrón: UntypedFormGroup y el any disfrazado
// ANTES (antipatrón): tipado destruido
const form: UntypedFormGroup = this.fb.group({ email: '', edad: 0 });
const edad = form.value.edad; // edad: any → cero seguridad, cero autocompletado
UntypedFormGroup/UntypedFormControl existen solo para migrar código pre-v14. En código nuevo son un any con otro nombre: perdés autocompletado, setValue/patchValue dejan de validar la forma del objeto y cualquier typo compila. El refactor es simplemente borrar el prefijo Untyped y dejar que la inferencia trabaje.
Signal Forms (estable en Angular 22)
Signal Forms es la API nueva, estable desde Angular 22, que importás de @angular/forms/signals. En vez de construir un árbol de FormControl, partís de un signal con el modelo de datos y lo envolvés con form(). El estado (valor, validez, errores, interacción) sale como signals, no como observables.
import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';
@Component({
selector: 'app-login',
imports: [FormField],
template: `
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<ul class="errores">
@for (err of loginForm.email().errors(); track err.kind) {
<li>{{ err.message }}</li>
}
</ul>
}
<input type="password" [formField]="loginForm.password" />
<button type="submit" [disabled]="loginForm().invalid()">Entrar</button>
`,
})
export class LoginComponent {
private readonly model = signal({ email: '', password: '' });
readonly loginForm = form(this.model, (path) => {
required(path.email, { message: 'El email es obligatorio' });
email(path.email, { message: 'Ingresá un email válido' });
required(path.password, { message: 'La contraseña es obligatoria' });
});
}
Las piezas clave:
form(model, schema?): crea elFieldTreea partir del signalmodel. El segundo argumento es la schema: una función que recibe lospathde los campos y declara reglas.[formField](directivaFormField): enlaza el input al campo con two-way binding y ya gestionadisabledautomáticamente.- Estado como signals: llamás al nodo como función (
loginForm.email()) y accedés avalue(),valid(),invalid(),errors(),touched(),dirty(),pending(),disabled(),readonly(),hidden(). - Validadores (
required,email,min,max,minLength,maxLength,pattern,validate,validateTree,validateHttp): se declaran en la schema, apuntando a unpath.
La diferencia conceptual con reactive forms es que en Signal Forms los validadores corren en un contexto reactivo: se re-evalúan solos cuando cambia cualquier signal que leen, incluso el valor de otro campo. Esto elimina de raíz la necesidad de suscribirse a valueChanges para reglas dependientes.
Reactive forms vs. Signal Forms
graph LR
subgraph Reactive["Reactive forms"]
R1[Árbol de FormControl] --> R2[valueChanges/statusChanges<br/>Observables]
R2 --> R3[Suscripción manual<br/>+ takeUntilDestroyed]
end
subgraph Signal["Signal Forms"]
S1[Signal del modelo] --> S2[form + schema]
S2 --> S3[Estado como signals<br/>reactivo automático]
end
| Dimensión | Reactive forms tipados | Signal Forms |
|---|---|---|
| Fuente de verdad | Árbol de FormControl | Signal del modelo de datos |
| Estado derivado | valueChanges/statusChanges (RxJS) | Signals (value(), valid(), …) |
| Validación dependiente | Suscribirse a valueChanges | Reactiva por construcción |
| Limpieza de suscripciones | Necesaria (takeUntilDestroyed) | No aplica |
| Madurez y ecosistema | Alta (años de uso, muchas libs) | Nueva en v22 |
| Encaje con signals/zoneless | Requiere toSignal | Nativo |
Cuándo elegir cada uno. Signal Forms encaja cuando el proyecto ya es signal-first y zoneless, y querés reglas cruzadas sin plumbing de RxJS. Reactive forms tipados siguen siendo la opción cuando necesitás integración madura con librerías existentes (Material, máscaras, editores) o dependés de operadores RxJS para orquestar el formulario. No hace falta migrar todo: podés convivir con ambos por feature.
Estrategias de validación
Independientemente del modelo, la regla de oro es una sola: la lógica de validación vive en un lugar (la clase/schema), y el template solo la muestra.
Validadores reutilizables
Un validador es una función pura; extraela una vez y reusala. En reactive forms:
import { AbstractControl, ValidationErrors } from '@angular/forms';
export function sinEspacios(control: AbstractControl): ValidationErrors | null {
const valor = control.value as string;
return valor?.includes(' ') ? { sinEspacios: true } : null;
}
En Signal Forms el equivalente es una función que devuelve { kind, message } o null, aplicada con validate:
validate(path.usuario, ({ value }) =>
value().includes(' ')
? { kind: 'sinEspacios', message: 'No puede contener espacios' }
: null,
);
Validación cruzada (cross-field)
Comparar dos campos (contraseña y confirmación, fecha desde/hasta) es donde los dos modelos más se diferencian.
Reactive forms: el validador va en el grupo, no en el control, y lee ambos hijos.
function passwordsIguales(group: AbstractControl): ValidationErrors | null {
const pass = group.get('password')?.value;
const confirm = group.get('confirm')?.value;
return pass === confirm ? null : { passwordMismatch: true };
}
const form = this.fb.nonNullable.group(
{ password: '', confirm: '' },
{ validators: [passwordsIguales] },
);
Signal Forms: el validador lee el otro campo con valueOf, y al correr en contexto reactivo se re-evalúa solo cuando cualquiera de los dos cambia.
validate(path.confirm, ({ value, valueOf }) =>
value() !== valueOf(path.password)
? { kind: 'passwordMismatch', message: 'Las contraseñas no coinciden' }
: null,
);
Para reglas cruzadas que abarcan varias ramas del árbol o que necesitan reportar el error en otro campo, Signal Forms ofrece validateTree.
Validación asíncrona
La validación asíncrona (¿este email ya está registrado?) exige cuidado con el rendimiento: una petición por tecla es un antipatrón que puede disparar decenas de requests por campo.
En reactive forms, un AsyncValidatorFn combinado con updateOn: 'blur' (más abajo) o debounceTime evita el bombardeo:
import { of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
emailDisponible(api: UsuariosApi): AsyncValidatorFn {
return (control) =>
api.existeEmail(control.value).pipe(
map((existe) => (existe ? { emailTomado: true } : null)),
catchError(() => of(null)),
);
}
En Signal Forms, validateHttp modela el caso HTTP declarativamente, con onSuccess/onError, y mientras corre el campo expone pending() para mostrar un spinner:
validateHttp(path.usuario, {
request: ({ value }) => `/api/usuarios/existe?u=${value()}`,
onSuccess: (res) => (res.taken ? { kind: 'usuarioTomado', message: 'Ya existe' } : null),
onError: () => ({ kind: 'red', message: 'No se pudo verificar' }),
});
Mostrar errores con buena UX: touched/dirty
Mostrar un error de “campo obligatorio” antes de que el usuario toque el campo es hostil. La convención:
touched: el usuario entró y salió del campo (blur). Es el disparador correcto para mostrar errores de validación de un campo individual.dirty: el usuario modificó el valor. Útil para habilitar un botón “Guardar” solo si hubo cambios reales.
<!-- Reactive forms -->
@if (form.controls.email.touched && form.controls.email.hasError('email')) {
<span class="error">Email inválido</span>
}
<!-- Signal Forms: mismo criterio, con signals -->
@if (form.email().touched() && form.email().invalid()) {
<span class="error">{{ form.email().errors()[0]?.message }}</span>
}
Al hacer submit conviene forzar markAllAsTouched() (reactive) o usar submit() (Signal Forms, que marca todos los campos como tocados) para revelar de golpe los errores pendientes.
Formularios dinámicos y grandes
FormArray para listas de controles
Cuando el usuario agrega/quita ítems (líneas de una factura, teléfonos, tags), FormArray es la estructura correcta. Tipalo con el control interno:
readonly telefonos = this.fb.array<FormControl<string>>([]);
agregar(): void {
this.telefonos.push(this.fb.control('', { nonNullable: true }));
}
quitar(i: number): void {
this.telefonos.removeAt(i);
}
En el template, renderizalo con @for usando track (ver capítulo Change Detection) para no recrear el DOM de toda la lista en cada cambio:
@for (ctrl of telefonos.controls; track ctrl) {
<input [formControl]="ctrl" />
}
Sin track adecuado, agregar un ítem al principio de la lista fuerza a Angular a re-renderizar todos los inputs (O(n) de trabajo de DOM evitable), y peor aún, se pierde el foco y el estado del input.
Rendimiento: updateOn para reducir trabajo
Por defecto, cada tecleo dispara valueChanges, re-corre todos los validadores del control y propaga estado hacia arriba en el árbol. En un formulario con 80 controles y validadores asíncronos, eso es una avalancha de trabajo por pulsación.
updateOn cambia cuándo se actualiza y valida un control:
// Valida solo al salir del campo, no en cada tecla
readonly form = this.fb.group(
{ email: '', comentario: '' },
{ updateOn: 'blur' },
);
// Un formulario que valida solo al enviar
readonly wizard = this.fb.group({ /* ... */ }, { updateOn: 'submit' });
'change'(default): máxima reactividad, máximo costo. Reservalo para campos con feedback instantáneo genuino.'blur': valida al perder el foco. Buen equilibrio para la mayoría de los campos, y reduce drásticamente las llamadas a validadores asíncronos.'submit': no valida hasta el envío. Ideal para formularios largos donde la validación por campo aporta poco.
Otras palancas para formularios grandes: dividir en subcomponentes con OnPush para acotar la detección de cambios, usar @defer para secciones que no están en viewport, y evitar computed/getters costosos dentro del template que se recalculen en cada ciclo.
Antipatrones a erradicar
1. any en los formularios. UntypedFormGroup, castear form.value as any, o tipar el modelo con any. Perdés toda la red de seguridad del compilador. Solución: reactive forms tipados con NonNullableFormBuilder o Signal Forms, ambos tipados de punta a punta.
2. Lógica de validación duplicada en template y clase. El patrón tóxico:
<!-- ANTES (antipatrón): la regla vive en el HTML Y en la clase -->
@if (form.value.edad < 18 || !form.value.email.includes('@')) {
<span>Datos inválidos</span>
}
Esa condición ya está (o debería estar) en los validadores. Duplicarla significa que un cambio de regla obliga a tocar dos lugares y tarde o temprano divergen. Solución: una definición de la regla en el validador; el template solo consulta hasError()/errors().
3. Suscribirse a valueChanges sin desuscribirse. El leak clásico de reactive forms:
// ANTES (antipatrón): fuga de memoria, la suscripción sobrevive al componente
ngOnInit() {
this.form.valueChanges.subscribe((v) => this.recalcular(v));
}
// DESPUÉS: atado al ciclo de vida
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((v) => this.recalcular(v));
}
El tema se cubre a fondo en RxJS con criterio. Con Signal Forms este antipatrón directamente desaparece: no hay observable al que suscribirse.
4. Validar solo en el cliente. Toda validación de formulario en el navegador es UX, no seguridad: un atacante saltea el formulario y pega directo contra la API. Cada regla que importe (unicidad, permisos, formato de negocio) debe re-validarse en el servidor. Se desarrolla en Seguridad.
El ciclo de estado de un control
Todo control (reactive o signal) transita estados independientes que combinás para decidir qué mostrar:
stateDiagram-v2
direction LR
[*] --> Pristine_Untouched
Pristine_Untouched --> Dirty: usuario modifica valor
Pristine_Untouched --> Touched: blur sin modificar
Dirty --> Touched: blur
Touched --> Dirty: modifica de nuevo
state Validez {
direction TB
Valid
Pending: validación async en curso
Invalid
Valid --> Pending: cambia el valor
Pending --> Valid
Pending --> Invalid
Invalid --> Pending
}
La clave: pristine/dirty (¿tocó el valor?) y touched/untouched (¿entró y salió?) son ortogonales a valid/invalid/pending. Mostrás el error cuando se cumplen ambas condiciones: el campo es inválido y el usuario ya interactuó (touched). Nunca solo por ser inválido.
Checklist
- Los formularios están tipados:
NonNullableFormBuilderen reactive forms o Signal Forms; ceroUntypedFormGroup/any. - Usás
nonNullablepara valores que no deben sernull, evitando el| nullque contamina todo elvalue. - Controles opcionales y grupos dinámicos modelados con interfaces explícitas o
FormRecord, no conany. - Cada regla de validación vive en un lugar (validador/schema); el template solo consulta el estado.
- Validadores reutilizables extraídos como funciones puras, no copiados entre formularios.
- La validación cruzada va en el grupo (reactive) o usa
valueOf/validateTree(Signal Forms). - La validación asíncrona usa
updateOn: 'blur',debounceTimeovalidateHttppara no disparar una petición por tecla. - Los errores se muestran con criterio
touched(+invalid), no antes de que el usuario interactúe. -
FormArrayrenderizado con@for+trackcorrecto; subcomponentesOnPushen formularios grandes. - Toda suscripción a
valueChangesusatakeUntilDestroyed()(o migraste a Signal Forms y no hay suscripción). - Toda regla que importe se re-valida en el servidor; la validación de cliente es solo UX.
Un formulario tipado y con validación bien ubicada depende de servicios (APIs, validadores async) que llegan por inyección. Cómo estructurar esa inyección para que sea testeable e intercambiable es el tema del próximo capítulo: Inyección de dependencias →