Formularios: reactive, tipados y Signal Forms

Por: Artiko
angularbuenas-practicasformulariossignal-formstyped-formsvalidacionrxjs

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:

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:

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ónReactive forms tipadosSignal Forms
Fuente de verdadÁrbol de FormControlSignal del modelo de datos
Estado derivadovalueChanges/statusChanges (RxJS)Signals (value(), valid(), …)
Validación dependienteSuscribirse a valueChangesReactiva por construcción
Limpieza de suscripcionesNecesaria (takeUntilDestroyed)No aplica
Madurez y ecosistemaAlta (años de uso, muchas libs)Nueva en v22
Encaje con signals/zonelessRequiere toSignalNativo

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:

<!-- 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' });

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

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 →