Angular Signal Forms: por qué reemplaza a Reactive Forms
Hace un par de semanas revisé un pull request de un formulario de checkout. Línea 40: const email = form.value.email as string. Le pregunté al autor por qué el cast. Silencio de dos segundos. Luego: "porque si no, TypeScript se queja".
Ese "se queja" es el síntoma de un problema real que Angular Signal Forms existe justamente para eliminar. En Reactive Forms, form.value.email no es string. Es string | null. Angular lo tipa así porque reset() limpia los valores a null, y el compilador no tiene forma de saber si ya hiciste reset o no — así que cada lectura del formulario viene con un cast disfrazado de costumbre.
Quince años escribiendo formularios en Angular. Quince años haciendo ese cast sin cuestionarlo. Angular Signal Forms, el nuevo sistema de formularios que llega con Angular v22, es la primera vez que veo una solución que no es un parche — es un cambio de premisa.
Los 3 problemas reales de Reactive Forms
Reactive Forms no es "malo" — lleva años en producción y funciona. Pero arrastra tres problemas estructurales que nacen de la misma raíz.
1. El tipado es una mentira
Por defecto, cada FormControl tiene tipo T | null, incluso cuando tu modelo de dominio nunca admite null en ese campo.
form = this.fb.group({
email: [39;39;, [Validators.required, Validators.email]],
password: [39;39;, Validators.required],
});
// form.value.email es string | null — no string
El tipo del formulario nunca coincide con el tipo del dominio. Terminas casteando con as, o duplicando la interfaz a mano con un NonNullable<T> que mantienes sincronizado manualmente. Ninguna de las dos opciones es tipado real — son formas distintas de callar al compilador.
2. El formulario es la fuente de verdad, no el modelo
En Reactive Forms, el estado vive dentro del FormGroup. Tu modelo de dominio es una consecuencia que extraes cuando lo necesitas, no el origen de la verdad.
Para extraerlo tienes cuatro caminos distintos, todos manuales: patchValue(), getRawValue(), reset() con un objeto de valores, o .get('email')?.setValue(x) campo por campo. Cuatro formas de sincronizar el mismo dato, cada una esperando a que se te olvide usarla en el momento correcto.
3. ControlValueAccessor es la interfaz más odiada de Angular
Cualquiera que haya construido un input custom en Angular conoce el ritual: implementar writeValue(), registerOnChange(), registerOnTouched(), setDisabledState(), y registrar un provider NG_VALUE_ACCESSOR en el componente.
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent),
multi: true,
}]
Son cerca de 20 líneas de boilerplate imperativo por cada control. Sin genéricos reales, sin signals, sin forma de que el compilador te ayude si te equivocas en el tipo del valor que emites.
Signal Forms: el reinicio conceptual
Aquí está el punto que más se malinterpreta: Signal Forms no es "Reactive Forms 2". No es una versión mejorada del mismo paradigma con signals encima. Es empezar de cero con una premisa distinta.
En Reactive Forms, el formulario manda y el modelo es una extracción. En Signal Forms, el modelo es un signal() normal y el formulario es una vista reactiva de ese signal. Cuando el usuario escribe en un input, el signal se actualiza. Cuando tú cambias el signal desde código, el formulario se actualiza solo. Ya no hay cuatro formas de sincronizar — solo tocas el signal.
Es el mismo giro conceptual que ya vimos con la reactividad fina de signals frente a callbacks y suscripciones manuales: una fuente de verdad única, y todo lo demás reacciona a ella. Signal Forms aplica esa misma idea al dominio de los formularios.
@Component({
imports: [FormField, FormRoot],
template: `
<form [formRoot]="loginForm">
<input [formField]="loginForm.email" type="email" />
@if (loginForm.email().invalid() && loginForm.email().touched()) {
<span>Email inválido</span>
}
<input [formField]="loginForm.password" type="password" />
<button type="submit">Entrar</button>
</form>
`
})
export class LoginComponent {
readonly loginModel = signal({ email: 39;39;, password: 39;39; });
readonly loginForm = form(this.loginModel, loginSchema);
}
Compara esto con el FormGroup de arriba. No hay fb.group(), no hay Validators.email como clase estática, no hay .get('email'). Hay un signal (loginModel) y una función (form()) que lo envuelve.
form(), FieldTree y FieldState
form(model, schema?) recibe tu modelo — y aquí hay una regla estricta: el modelo debe ser un WritableSignal<T>, un signal() normal y escribible. Si le pasas un computed(), no compila. No es un "formulario de solo lectura" — es directamente un error de tipos, porque Signal Forms necesita poder escribir de vuelta en el modelo cuando el usuario interactúa.
form() devuelve un FieldTree<T>. Accedes a cada campo por dot-notation: loginForm.email es un nodo de ese árbol. Al invocarlo como función — loginForm.email() — obtienes un FieldState, que expone todo como signals: value() (un WritableSignal), dirty(), touched(), invalid(), valid(), errors(), pending(), disabled(), readonly(), required(), hidden(). Y métodos como markAsTouched(), markAsDirty(), reset(). No existe ningún tipo llamado "FieldNode" — es FieldTree para la estructura y FieldState para el estado de un campo concreto.
Sin módulos, solo directivas standalone
No existe ningún FormsSignalsModule. Las dos piezas que necesitas son directivas standalone: FormField (aporta [formField], va en cada input) y FormRoot (aporta [formRoot], va en el <form>). Se importan una por una: imports: [FormField, FormRoot].
Signal Forms vive en @angular/forms/signals, ya incluido si tienes @angular/forms en v21 o superior. No necesitas ningún provider global adicional en app.config.ts — es standalone de principio a fin.
[formRoot] es la pieza nueva de v21.2/v22 que más simplifica el día a día: aplica novalidate automáticamente, intercepta el submit nativo, hace preventDefault y dispara el envío del FieldTree sin que tengas que escribir un (ngSubmit) manual. El estilo clásico de <form> + (submit) sigue funcionando si lo prefieres, pero el camino recomendado en Signal Forms es el declarativo con [formRoot].
Esto es exactamente el tipo de arquitectura que estamos actualizando a v22 en el curso de Angular Moderno — el objetivo no es memorizar la API nueva, sino entender por qué el modelo manda ahora en vez del framework.
Validación con schema declarativo
La validación se declara aparte, con schema<T>((path) => { ... }). La convención oficial nombra el parámetro path — no f, no form, no otro nombre. Vale la pena respetarla porque es lo que vas a ver en toda la documentación y en el código de otros equipos.
const loginSchema = schema<LoginForm>((path) => {
required(path.email);
email(path.email);
required(path.password);
minLength(path.password, 8);
});
Los validadores built-in siguen todos el mismo patrón, con el path primero: required(path.campo), email(path.campo), minLength(path.campo, n), maxLength(path.campo, n), min(path.campo, n), max(path.campo, n), pattern(path.campo, regex).
Si escribes un validador custom, hay una regla que no es opcional: debe devolver undefined en caso de éxito, nunca null. Es la "regla del undefined" — un detalle pequeño que rompe la validación entera si lo pasas por alto viniendo de la costumbre de Reactive Forms, donde null era la señal de "todo bien".
Controles custom sin ControlValueAccessor
Los controles custom en Signal Forms se implementan con la interfaz FormValueControl<T>, que reemplaza a ControlValueAccessor con un solo miembro obligatorio: value, como ModelSignal<T> creado con model(). Todo lo demás — disabled, errors, touched, required — son input() opcionales.
Es aquí donde Signal Forms cambia más el día a día si construyes design systems o librerías de componentes internas. ControlValueAccessor exige implementar cuatro métodos y registrar un provider, como vimos arriba.
export class CustomInputComponent implements FormValueControl<string> {
readonly value = model(39;39;); // único miembro REQUERIDO
readonly disabled = input(false); // opcional
readonly errors = input<ValidationError[]>([]); // opcional
}
Eso es todo. Sin forwardRef, sin NG_VALUE_ACCESSOR, sin registerOnChange guardando una función en una propiedad privada. El compilador conoce el tipo real del valor porque model<string>() lo declara, no porque tú lo prometas en un comentario.
El estado real de madurez en v22 — sin exagerar
Signal Forms es @experimental desde Angular v21. En v22, la API core madura significativamente — el comportamiento de form(), [formField], schema() y los validadores built-in se estabiliza. Pero el marcado @experimental puede seguir presente en algunos subconjuntos de la API. Funciones más avanzadas, como validateAsync() o compatForm(), pueden tener cambios menores antes de la estabilización oficial completa.
Este es exactamente el tipo de dato que el ecosistema Angular audita de cerca, así que voy a ser preciso: "ya es estable, úsalo sin pensar" sería una simplificación que no te sirve para decidir en producción. Puedes verificar el estado exacto de cada API en la documentación oficial de formularios de Angular.
En la práctica, esto se traduce en tres escenarios:
| Escenario | Recomendación |
|---|---|
| Código nuevo en tu proyecto propio o side project | Úsalo sin reservas |
| Código nuevo en un proyecto de empresa | Evalúa el riesgo, documenta la decisión con el equipo |
| Migrar formularios legacy críticos | Espera a la estabilización completa |
Si decides adoptarlo ahora, blindarlo con tests importa más que nunca — y el cambio de paradigma en testing es tan grande como en los formularios mismos. Testear un FieldTree no es testear un FormGroup: lees el value() directamente como signal, sin suscribirte a valueChanges ni esperar un ciclo extra de detección de cambios para que el observable propague. Es exactamente el tipo de ajuste que cubrimos en el curso de Testing en Angular actualizado a este modelo.
Reactive Forms vs Signal Forms — comparativa
| Reactive Forms | Signal Forms | |
|---|---|---|
| Tipado | T | null por defecto, casts frecuentes |
El tipo del modelo es el tipo real, sin null fantasma |
| Fuente de verdad | El FormGroup — el modelo se extrae |
El signal() del modelo — el form es una vista |
| Controles custom | ControlValueAccessor, ~20 líneas, sin genéricos |
FormValueControl<T>, solo value requerido |
| Boilerplate | Alto: provider, 4 métodos, forwardRef |
Bajo: directivas standalone, sin módulo |
| Estado de madurez (v22) | Estable, años en producción | @experimental, API core estable en comportamiento |
La tesis
Signal Forms no es una feature más de la lista de novedades de v22. Es la misma premisa que ya cambió cómo pensamos la reactividad con signal(), computed() y effect(), aplicada ahora al dominio de los formularios: el modelo manda, el framework refleja.
Angular lleva desde v16 moviéndose hacia signals-first, y Signal Forms es la pieza que faltaba para que esa filosofía cubriera también la parte más tediosa de cualquier aplicación real. Si quieres ver dónde encaja dentro del resto de cambios de esta versión, lo cubrimos en el repaso de novedades de Angular v22.
Lo que puedes hacer hoy: si tienes un side project o un proyecto nuevo sin presión de legacy, monta el próximo formulario con Signal Forms. No esperes a que el @experimental desaparezca del todo — la API core ya se comporta como se va a comportar. Si estás en un proyecto de empresa, documenta la decisión y evalúa el riesgo con tu equipo antes de migrar nada crítico.
En Dominicode Labs estamos ya construyendo con Signal Forms en los proyectos de la comunidad — si quieres ver los patrones reales, sin el filtro del ejemplo de documentación, es ahí donde está pasando.
Preguntas frecuentes sobre Angular Signal Forms
¿Qué es Angular Signal Forms?
Es el nuevo sistema de formularios de Angular, disponible desde v21 como @experimental y madurando en v22. En vez de que el formulario sea la fuente de verdad, el modelo es un signal() normal y el formulario es una vista reactiva de ese signal. Se construye con form(model, schema), que devuelve un FieldTree, y se conecta al template con las directivas standalone [formField] y [formRoot].
¿Signal Forms reemplaza a Reactive Forms?
Conceptualmente sí, pero no de un día para otro. Reactive Forms sigue siendo estable y soportado. Signal Forms no es "Reactive Forms con signals encima" — es un sistema construido desde cero sobre la premisa de que el modelo manda. Para proyectos nuevos, es la dirección a seguir. Para formularios existentes en producción, migrarlos no es urgente todavía.
¿Puedo usar Signal Forms en producción en Angular 22?
Depende del contexto. En tu proyecto propio, sí, sin reservas — la API core (form(), [formField], schema(), validadores built-in) se estabiliza en comportamiento en v22. En un proyecto de empresa, evalúa el riesgo y documenta la decisión, porque el marcado @experimental puede seguir en algunos subconjuntos de la API. Para migrar formularios legacy críticos, espera a la estabilización oficial completa.
¿Necesito importar un módulo para usar Signal Forms?
No. No existe ningún FormsSignalsModule. Las directivas FormField y FormRoot son standalone y se importan directamente en el array imports del componente. Tampoco necesitas ningún provider adicional en app.config.ts — solo tener @angular/forms en v21 o superior.
¿Cómo se hacen controles de formulario custom en Signal Forms?
Con la interfaz FormValueControl<T>, que reemplaza a ControlValueAccessor. Solo necesitas un miembro obligatorio: value como ModelSignal<T> creado con model(). Propiedades como disabled, errors, touched o required son input() opcionales, sin necesidad de provider ni forwardRef.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
