
Angular Signal Forms: Qué Cambia y Cómo Usarlos
Tabla de Contenidos
Tabla de Contenidos
Angular ha cambiado mucho, y para bien. Los Reactive Forms han sido el estándar durante años, pero arrastran la complejidad de un mundo pre-Signals: verbosidad, dependencia total de RxJS y un tipado que a veces se siente forzado.
Desde la introducción de Signals, todos nos preguntamos si era posible utilizarlos para implementar formularios. Ese día llegó: desde Angular v21 tenemos Signal Forms (experimentales al principio, estables desde Angular v22) para simplificar la creación de nuestros formularios y la gestión de su estado.
1. Los Bloques Básicos
Un Signal Form no es un conjunto de piezas que tienes que ensamblar. Es una cadena: empiezas con un signal y cada paso transforma el anterior.
El modelo es un signal con los datos en crudo. Es la única fuente de verdad y no hay que replicar su estructura en ningún FormGroup.
form() es el factory. Recibe ese signal y devuelve un FieldTree tipado con la misma forma del modelo. No copia los datos: el signal que le pasas sigue siendo el dueño de la verdad. Como segundo argumento acepta un schema, que es donde declaras las reglas.
El FieldTree es por donde navegas. Escribes form.contact.email y el compilador te autocompleta la ruta y te avisa si el campo no existe.
El FieldState aparece cuando ejecutas un nodo del árbol: form.contact.email(). Ahí viven los signals del campo: value(), valid(), touched(), errors(). Un nodo del árbol es la ruta; invocarlo es preguntar por su estado ahora mismo.
Las directivas [formRoot] y [formField] cierran la cadena y atan todo eso a los elementos del HTML.
Esa distinción entre el árbol y el estado (la ruta y lo que hay al final de la ruta) es la decisión de diseño que más te va a costar interiorizar si vienes de Reactive Forms, y es de la que cuelga todo lo demás.
2. El Escenario: Formulario de Registro
Nuestro ejemplo será un RegistrationForm. Lo primero que notarás es que el formulario "nace" de un signal tipado. No hay new FormGroup ni nada parecido:
// registration-form.ts
import { signal, Component, ChangeDetectionStrategy } from "@angular/core";
import { form, FormField, FormRoot, required, email } from "@angular/forms/signals";
// Este va a ser nuestro modelo de datos, la estructura de nuestro formulario
interface RegistrationData {
firstName: string;
lastName: string;
contact: { email: string; phone: string };
addresses: { street: string; city: string }[];
notifications: boolean;
}
@Component({
selector: "app-registration-form",
imports: [FormField, FormRoot],
templateUrl: "./registration-form.html"
})
export class RegistrationFormComponent {
// 1. Nuestra única fuente de verdad (el modelo)
protected readonly registrationModel = signal<RegistrationData>({
firstName: "",
lastName: "",
contact: { email: "", phone: "" },
addresses: [{ street: "", city: "" }],
notifications: true,
});
// 2. El motor del formulario y sus reglas
protected readonly registrationForm = form(this.registrationModel, (f) => {
required(f.firstName);
required(f.lastName);
email(f.contact.email);
});
}
3. Acceder a un Campo: FieldTree vs FieldState
Ya vimos la teoría arriba; aquí es donde se nota en el día a día. Compara cómo accedías a un campo antes y cómo lo haces ahora:
// ❌ Antes (Reactive Forms): registrationForm era un FormGroup
const emailControl = this.reactiveForm.get("contact.email");
const emailValue = emailControl?.value; // string | null | undefined. Quién sabe.
// ✅ Ahora (Signal Forms): registrationForm es un FieldTree
const emailState = this.registrationForm.contact.email(); // invocas el nodo para leer su estado
const currentEmail = emailState.value(); // string, tipado
const isInvalid = emailState.invalid(); // boolean
4. Mutando Datos: Granularidad Total
El detalle que cambia la forma de trabajar: dentro de un FieldState, la propiedad value es un WritableSignal, mientras que el resto del estado (valid(), touched(), errors()) son signals de solo lectura. Según lo que quieras cambiar, tienes dos caminos.
Un solo campo → vas por su estado. Como value es un WritableSignal, usas .set() o .update():
this.registrationForm.firstName().value.set("Arcadio");
La estructura (añadir o quitar elementos de un array, reemplazar un objeto entero) → vas por el modelo. Un array es un array normal: no hay FormArray, ni push(), ni removeAt(). Usas spread y filter, y el FieldTree se re-deriva solo:
addAddress() {
this.registrationModel.update(v => ({
...v,
addresses: [...v.addresses, { street: '', city: '' }]
}));
}
removeAddress(index: number) {
this.registrationModel.update(v => ({
...v,
addresses: v.addresses.filter((_, i) => i !== index)
}));
}
En los dos casos el cambio se propaga en las dos direcciones (campo → modelo y modelo → campos), y Angular repinta solo la parte de la UI que dependía del dato que cambió. Adiós a FormArray.push(), a patchValue() y a acordarse de emitir eventos.
5. Qué Hacemos en el Template
Para conectar el formulario con la vista hay dos directivas. formField va en cada input y lo ata a su campo del árbol. formRoot va en la etiqueta form y hace dos cosas por ti: pone novalidate (silencia la validación nativa del navegador para que mande la tuya) y engancha el submit del formulario a Signal Forms, sin que tengas que llamar a preventDefault() a mano.
<!-- registration-form.html -->
<form [formRoot]="registrationForm">
<div>
<label>First Name</label>
<!-- Pasamos el FieldTree directamente -->
<input [formField]="registrationForm.firstName" />
<!-- Invocamos el state () para evaluar el error reactivamente -->
@if (registrationForm.firstName().invalid() && registrationForm.firstName().touched()) {
<p class="text-red-600">First name is required.</p>
}
</div>
<!-- Iteramos el array del form. track $index sirve para el ejemplo;
si reordenas o borras del medio, usa una clave estable del modelo -->
@for (dir of registrationForm.addresses; track $index) {
<div>
<input [formField]="dir.street" placeholder="Street" />
<input [formField]="dir.city" placeholder="City" />
<button type="button" (click)="removeAddress($index)">Eliminar</button>
</div>
}
<button type="button" (click)="addAddress()">Añadir dirección</button>
<button type="submit" [disabled]="registrationForm().invalid()">Registrarme</button>
</form>
El template también está tipado. Si escribes [formField]="registrationForm.wrongField", el compilador de Angular te avisa antes de que llegues al navegador.
6. Validación: Lo que Trae de Fábrica
Las reglas se declaran dentro de la función de schema que le pasas a form(). Cada una apunta a un campo del árbol, y con eso el compilador te avisa si te equivocas de ruta:
import { form, required, email, minLength, pattern } from "@angular/forms/signals";
protected readonly registrationForm = form(this.registrationModel, (f) => {
required(f.firstName, { message: "El nombre es obligatorio" });
minLength(f.firstName, 2);
required(f.contact.email);
email(f.contact.email);
pattern(f.contact.phone, /^\+?[0-9]{9,15}$/);
});
v22 amplió el catálogo. Además de required y email, ahora tienes min / max para números, minLength / maxLength, pattern, y minDate / maxDate para fechas. Y no solo validan: disabled, readonly y hidden controlan el estado del campo de forma reactiva, y debounce retrasa cada cuánto el input escribe en el modelo (justo lo que antes hacías con debounceTime de RxJS).
Un extra que antes no existía: los límites aceptan un valor o una función. Así un máximo puede depender de otro campo, sin escribir un validador propio:
// El máximo de "invitados" nunca supera las plazas disponibles
max(f.guests, () => f.availableSeats().value());
Y cuando una regla no encaja en ninguna de las anteriores, validate() te deja escribir la tuya:
validate(f.lastName, ({ value }) =>
value().includes(" ") ? { kind: "sin-espacios", message: "Sin espacios" } : undefined
);
¿Y si la validación es muy compleja?
Cuando la validación es enorme, o cuando prefieres tenerla en un solo sitio y compartirla con el backend, declararla campo a campo se queda corto. Para eso, Signal Forms acepta Standard Schemas: puedes enchufar un schema de Zod como una regla más con validateStandardSchema(f, UserSchema). Convive con las funciones nativas y los errores de ambos terminan en el mismo errors(). Es el extra para cuando lo de fábrica no basta, no el punto de partida.
7. El Envío
Para el envío, Signal Forms trae un helper submit() que hace bastante más que comprobar valid(): marca todos los campos como touched (así los errores ocultos salen a la luz), expone un signal submitting() mientras corre tu acción, y bloquea envíos concurrentes para que un doble clic no dispare dos peticiones.
La forma más limpia es declarar la lógica de envío al crear el formulario, como tercer argumento de form():
protected readonly registrationForm = form(
this.registrationModel,
(f) => {
required(f.firstName);
email(f.contact.email);
},
{
submission: {
// Solo se ejecuta si el formulario es válido.
// El modelo está tipado: sin campos undefined ni any.
action: async (form) => {
console.log("Datos a enviar:", form().value());
return []; // sin errores de servidor
},
},
}
);
Como el formulario ya lleva su submission dentro, [formRoot] se encarga de disparar el envío solo. En el template no necesitas ningún handler: un botón type="submit" basta.
<form [formRoot]="registrationForm">
<!-- ...campos... -->
<button type="submit" [disabled]="registrationForm().invalid()">Registrarme</button>
</form>
Si el backend rechaza algo (un email ya registrado, por ejemplo), el action devuelve el error y submit() lo cuelga del campo correcto. La validación de servidor y la de cliente terminan en el mismo errors().
Nota: si prefieres no meter la lógica en form(), puedes disparar el envío tú mismo llamando a submit(this.registrationForm, async () => { ... }) desde un (click) del botón o un (submit) del form. Misma función, misma ventaja; solo cambia dónde vive la acción.
Bonus: Más Allá del Flujo Básico
Con las siete secciones anteriores cubres la gran mayoría de los formularios. Estos dos extras resuelven casos que con Reactive Forms eran incómodos.
Controles Custom sin ControlValueAccessor
Si alguna vez escribiste un control custom con ControlValueAccessor, sabes el ritual: implementar writeValue, registerOnChange, registerOnTouched, setDisabledState y registrar un provider NG_VALUE_ACCESSOR. Mucho boilerplate para, en el fondo, exponer un valor.
Signal Forms lo reemplaza con un contrato de una sola propiedad obligatoria. Implementas FormValueControl y expones un value como model():
import { model, input, booleanAttribute } from "@angular/core";
import { FormValueControl } from "@angular/forms/signals";
@Component({ selector: "app-toggle", /* ... */ })
export class ToggleComponent implements FormValueControl<boolean> {
// Lo único obligatorio: un model() que Signal Forms mantiene en sync.
readonly value = model(false);
// Opcionales, si los quieres reflejar en la UI:
readonly touched = input(false);
readonly disabled = input(false, { transform: booleanAttribute });
}
El contrato también expone como inputs opcionales invalid y errors (además de readonly, required, min, max, pattern). Si los declaras, Signal Forms te pasa el estado de validación del campo y tu control puede pintar el borde rojo o el mensaje de error sin que le cables nada:
readonly invalid = input(false, { transform: booleanAttribute });
readonly errors = input<readonly ValidationError[]>([]);
Y ya está. Sin writeValue, sin providers, sin registerOnChange. Lo conectas con la misma directiva [formField] que cualquier input nativo:
<app-toggle [formField]="registrationForm.notifications" />
Búsqueda con debounce, sin RxJS
Un clásico: un input de búsqueda que no debe pegarle al backend en cada tecla. Antes lo resolvías con una tubería de RxJS sobre valueChanges:
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q))
).subscribe(/* ... */);
Con Signal Forms lo declaras como una regla más del schema, y el valor del modelo ya llega espaciado. Nada de subscribe, ni de acordarte de desuscribir:
searchForm = form(this.searchModel, (f) => {
debounce(f.query, 300); // el modelo se actualiza como mucho cada 300ms
});
A partir de ahí, cualquier effect o httpResource que dependa de searchModel().query se dispara ya debounced. Adiós a debounceTime + switchMap.
¿Qué Sigue?
Hemos pasado de un modelo tipado a manejar arrays dinámicos, validar con las utilidades nativas y enviar al backend, sin perder el tipado en ningún momento.
El salto mental de Reactive Forms a Signal Forms cuesta, pero el equipo de Angular ha construido un muy buen reemplazo: hasta ahora no he encontrado un caso que no pueda resolver con Signal Forms. Lo más complejo suele ser la validación dinámica, los campos que dependen unos de otros, y hoy la mayoría de eso ya se resuelve directamente con la librería.
Para seguir tirando del hilo:
- Prueba crear un panel de depuración que muestre
registrationForm().errors()en tiempo real. - Revisa la documentación oficial para los validadores asíncronos (
validateAsync,validateHttp).
¡Feliz programación!


