Programacion & Dev

Angular 22: Reemplace ControlValueAccessor con Signal Forms

Descubre cómo Signal Forms en Angular 22 optimiza el desarrollo de formularios, permitiendo crear controles personalizados con menos código.

Angular 22: Reemplace ControlValueAccessor con Signal Forms

El problema que resuelven Signal Forms

Hay un momento específico que todo desarrollador de Angular conoce. Necesitas un componente de entrada personalizado: un selector de fechas, un widget de calificación, un campo de texto enriquecido. Y sabes lo que viene: array de providers, forwardRef, cuatro métodos de interfaz, callbacks manuales de onChange y onTouched. Cada vez. Para cada control.

Signal Forms no simplifica esto. Lo reemplaza por completo.

El segundo punto de dolor es la gestión del estado. En Reactive Forms, el estado del formulario reside en FormControl y FormGroup — basado en Observables, imperativo para actualizar, frágil de tipar correctamente con estructuras anidadas. Observar un cambio en un campo significa suscribirse a valueChanges, gestionar la suscripción y cancelar la suscripción al destruir. Signal Forms convierte cada pieza de estado en una signal: legible, componible y limpiada automáticamente.

⚠️ Importante: ControlValueAccessor está no obsoleto en Angular 22. Sigue funcionando y los controles existentes aún se compilan. Pero para cada nuevo control, Signal Forms es el camino a seguir. Angular 22 garantiza la compatibilidad total entre ambos mundos.

No tienes que migrar todo. Solo tienes que dejar de escribir de la forma antigua para todo lo nuevo.


La nueva API: tres primitivas

Aprende estas tres y habrás aprendido Signal Forms.

form()

El punto de entrada. Pasa una signal (o linkedSignal) que contenga tu modelo y una función de validación. Devuelve un FieldTree tipado: un espejo de la forma de tu modelo donde cada hoja es un campo reactivo con valor, validez, estado dirty, touched y pending.

FormValueControl<T>

La interfaz que implementa tu componente personalizado en lugar de ControlValueAccessor. Requisito mínimo: exponer una signal value = model<T>(). La directiva conecta todo lo demás: sincroniza la signal de valor del campo, propaga touched/dirty y maneja el estado disabled.

SignalFormControl

Un puente para la migración incremental. Inserta un control basado en signals en un FormGroup existente sin reescribir el formulario padre. Los errores de validación y el estado se propagan en ambos sentidos: de Signal Form al árbol de Reactive Forms y viceversa.

[formField] directiva

La directiva de enlace de plantilla. Aplícala a cualquier elemento junto con ngModel y conectará el estado del campo del formulario. Establece clases CSS (.ng-valid, .ng-dirty, .ng-touched), gestiona atributos ARIA y mantiene el valor sincronizado automáticamente.


Construyendo un formulario con validación

La función form() toma tu signal de modelo y una función de esquema. Los validadores se ejecutan sobre el valor del campo: ya no hay que pasar arrays de validadores esperando que los tipos coincidan.

TYPESCRIPT
import { Component, inject, signal }           from '@angular/core';import { form, required, minLength, pattern }  from '@angular/forms/signals';import { UserService }                          from './user.service';interface RegisterModel {  username: string;  email: string;  password: string;}@Component({  selector: 'app-register',  standalone: true,  imports: [SignalFormsModule],  template: `    <form (ngSubmit)="submit()">      <label>Username        <input [formField]="f.username" />        @if (f.username().errors()?.required) {          <span class="error">Required</span>        }        @if (f.username().errors()?.minLength) {          <span class="error">Min 3 characters</span>        }        @if (f.username().pending()) {          <span class="info">Checking availability…</span>        }      </label>      <label>Email        <input type="email" [formField]="f.email" />        @if (f.email().errors()?.pattern) {          <span class="error">Invalid email format</span>        }      </label>      <label>Password        <input type="password" [formField]="f.password" />      </label>      <button type="submit" [disabled]="!f.valid()">Register</button>    </form>  `,})export class RegisterComponent {  private userSvc = inject(UserService);  // Model as a writable signal  private model = signal<RegisterModel>({    username: '',    email:    '',    password: '',  });  // form() generates the FieldTree — typed to RegisterModel  f = form(this.model, (field) => {    required(field.username);    minLength(field.username, 3);    // Async validator — checks username availability    field.username.addAsyncValidator(async (value) => {      if (!value) return null;      const taken = await this.userSvc.isUsernameTaken(value);      return taken ? { usernameTaken: true } : null;    }, { debounce: 400 }); // built-in debounce — no more timer juggling    required(field.email);    pattern(field.email, /^[^\s@]+@[^\s@]+\.[^\s@]+$/);    required(field.password);    minLength(field.password, 8);  });  submit() {    if (!this.f.valid()) return;    console.log(this.model()); // typed RegisterModel — no .getRawValue()  }}

✅ Perspectiva clave: La signal de modelo es la fuente de verdad. form() no posee los datos: tu signal sí. Leer this.model() te da el valor tipado actual, sin .getRawValue(), sin .value, sin extracción manual. Signal Forms son una lente sobre tus datos, no un contenedor para ellos.


Controles personalizados: antes y después

Esta es la mayor mejora en la calidad de vida de toda la versión.

ControlValueAccessor tradicional

Implementación con múltiples métodos: writeValue, registerOnChange, registerOnTouched, setDisabledState

Uso de variables privadas para gestionar callbacks onChange y onTouched

Código extenso (~40 líneas) y boilerplate para manejar estado y eventos

Necesidad de definir providers y usar forwardRef para integración

Complejidad mayor para controles personalizados

FormValueControl simplificado

Reducción significativa de código y complejidad

Uso directo de signals para manejar el estado del control

Menos métodos y lógica explícita para callbacks

Mejora en la calidad de vida del desarrollador

Facilita la creación y mantenimiento de controles personalizados

Antes — ControlValueAccessor (~40 líneas)

TYPESCRIPT
@Component({  selector: 'app-rating',  providers: [{    provide: NG_VALUE_ACCESSOR,    useExisting: forwardRef(() => RatingComponent),    multi: true,  }],})export class RatingComponent implements ControlValueAccessor {  protected value    = 0;  protected disabled = false;  private onChange: (v: number) => void = () => {};  private onTouched: () => void         = () => {};  writeValue(v: number) {    this.value = v ?? 0;  }  registerOnChange(fn: (v: number) => void) {    this.onChange = fn;  }  registerOnTouched(fn: () => void) {    this.onTouched = fn;  }  setDisabledState(d: boolean) {    this.disabled = d;  }  select(star: number) {    this.value = star;    this.onChange(star);    this.onTouched();  }}

Después — FormValueControl (3 líneas)

TYPESCRIPT
import { model, Component }  from '@angular/core';import { FormValueControl }  from '@angular/forms/signals';@Component({  selector: 'app-rating',  standalone: true,})export class RatingComponent implements FormValueControl<number> {  // This single signal is the entire API  value = model(0);  // Optional inputs — Signal Forms wires them automatically  // disabled?: InputSignal<boolean>;  // touched?:  InputSignal<boolean>;  // name?:     InputSignal<string>;  select(star: number) {    this.value.set(star);    // No manual onChange/onTouched calls needed    // The directive handles all propagation  }}// Usage in template — identical to before:// <app-rating [formField]="f.rating" />

El contrato del componente pasó de 4 métodos obligatorios + un array de providers + una llamada forwardRef a una signal. Eso es todo. Todo lo demás (sincronización, seguimiento de dirty, propagación de touched, estado disabled) es manejado automáticamente por la directiva [formField].


Editando datos del servidor: linkedSignal + httpResource

El patrón más potente en Signal Forms: cargar datos mediante httpResource, vincularlos al formulario mediante linkedSignal, y el formulario se actualiza automáticamente cada vez que el recurso se recarga.

TYPESCRIPT
import { Component, inject, input }           from '@angular/core';import { httpResource }                        from '@angular/common/http';import { linkedSignal }                        from '@angular/core';import { form, required, minLength }           from '@angular/forms/signals';interface UserProfile {  username: string;  bio:      string;  website:  string;}@Component({  selector: 'app-edit-profile',  standalone: true,  template: `    @if (userResource.isLoading()) {      <p>Loading profile...</p>    } @else {      <form (ngSubmit)="save()">        <input [formField]="f.username" placeholder="Username" />        <textarea [formField]="f.bio" placeholder="Bio"></textarea>        <input [formField]="f.website" placeholder="Website" />        <button [disabled]="!f.valid() || !f.dirty()">Save changes</button>        <button type="button" (click)="reset()">Discard</button>      </form>    }  `,})export class EditProfileComponent {  userId = input.required<number>();  // 1. Fetch from server — refetches when userId changes  userResource = httpResource<UserProfile>(    () => `/api/users/${this.userId()}`  );  // 2. Bridge resource → writable signal via linkedSignal  //    Updates automatically when userResource reloads  editableProfile = linkedSignal(    () => this.userResource.value() ?? { username: '', bio: '', website: '' }  );  // 3. Form observes editableProfile — resets when profile reloads  f = form(this.editableProfile, (field) => {    required(field.username);    minLength(field.username, 3);    required(field.bio);  });  save() {    if (!this.f.valid()) return;    // this.editableProfile() holds the current form values    console.log('Saving:', this.editableProfile());  }  reset() {    // Force linkedSignal to re-read from the resource    this.editableProfile.set(this.userResource.value()!);  }}

Este patrón elimina toda una categoría de código de los formularios de edición: no más ngOnInitPatrón de edición de datos del servidor con Signal FormspatchValue(), no más suscripción a parámetros de ruta para activar una recarga, no más lógica de reinicio manual. El grafo reactivo se encarga de todo.


Validación entre campos

La validación entre campos solía requerir un validador personalizado a nivel de FormGroup con una API incómoda. En Signal Forms, un validador en un campo puede leer directamente la signal de cualquier campo hermano.

TYPESCRIPT
interface PasswordForm {  password: string;  confirm:  string;}f = form(signal<PasswordForm>({ password: '', confirm: '' }), (field) => {  required(field.password);  minLength(field.password, 8);  required(field.confirm);  // Cross-field rule: reads the sibling field's signal directly  field.confirm.addValidator((confirmValue) => {    const pwd = field.password.value(); // sibling signal — no workarounds    return confirmValue !== pwd      ? { passwordMismatch: true }      : null;  });});// In the template:// @if (f.confirm().errors()?.passwordMismatch) {//   <span>Passwords don't match</span>// }

Reactive Forms vs Signal Forms

Criterio

Reactive Forms

Signal Forms (Angular 22)

Modelo de estado

Basado en Observable: suscribirse, cancelar suscripción, gestionar ciclo de vida

Basado en Signal: leer, derivar, limpieza automática ✅

Controles personalizados

ControlValueAccessor — 4 métodos + provider + forwardRef (~40 líneas)

FormValueControl — exponer signal model() (3 líneas) ✅

Formularios anidados

FormGroup dentro de FormGroup — la inferencia de tipos falla en profundidad

La forma del modelo se refleja naturalmente — FieldTree está totalmente tipado ✅

Validación asíncrona

Devolver Observable o Promise — configuración manual de debounce

addAsyncValidator con { debounce: 400 } integrado ✅

Reglas entre campos

Validador personalizado a nivel de FormGroup — API incómoda

Leer la signal de cualquier hermano directamente dentro del esquema ✅

Enlace de datos del servidor

Manual patchValue() después de la respuesta HTTP

linkedSignal + httpResource — sincronización automática ✅

Leer el valor

form.getRawValue() o form.value (posibles nulos)

model() — siempre el valor tipado completo, sin nulos ✅

Compatibilidad

Independiente

Compatible con CVA y Reactive Forms mediante SignalFormControl

Cuándo mantener

Formularios existentes que funcionan: no migres lo que no está roto

Todos los formularios nuevos a partir de hoy


Migrando sin un gran cambio

La migración correcta es incremental. Angular 22 garantiza que ControlValueAccessor y FormValueControl son mutuamente compatibles: puedes tener ambos en el mismo formulario sin necesidad de soluciones alternativas.

Evolución de Signal Forms

Versión

Estado

v21

Experimental: API inestable entre versiones menores. No recomendado para producción.

v22

Estable: API pública. form(), FormValueControl, SignalFormControl, [formField], ngNoCva, y el puente compatForm son todos estables.

El manual de migración de cuatro pasos

Paso 1: migrar primero los controles personalizados. Reemplaza ControlValueAccessor con FormValueControl un control a la vez. Angular 22 hace que los componentes FormValueControl sean totalmente compatibles con los padres FormGroup existentes: no se necesitan cambios en el padre.

Manual de migración de cuatro pasos
Paso 1
Migrar controles personalizados

Replace ControlValueAccessor with FormValueControl one control at a time without changing parent FormGroups.

Paso 2
Escribir nuevos formularios

Develop new forms using Signal Forms to maximize migration ROI; leave working forms unchanged.

Paso 3
Usar SignalFormControl

Insert signal-based controls into existing FormGroups to enable two-way validation error propagation.

Paso 4
Añadir ngNoCva

Apply ngNoCva directive when mixing legacy and Signal Forms APIs to avoid conflicts.

Una guía concisa para migrar incrementalmente formularios de Angular usando Signal Forms.

Paso 2: escribir todos los formularios nuevos con Signal Forms. No toques los formularios que funcionan. El ROI de la migración está en el nuevo desarrollo (donde Signal Forms elimina la configuración por completo), no en reescribir formularios que ya funcionan.

Paso 3: usar SignalFormControl como puente. Inserta un control basado en signals dentro de un FormGroup existente. Los errores de validación se propagan en ambos sentidos: sin doble contabilidad.

TYPESCRIPT
import { SignalFormControl } from '@angular/forms/signals';import { FormGroup }         from '@angular/forms';// Drop a signal-based control into an existing FormGroup — no rewrite neededconst legacyForm = new FormGroup({  firstName: new FormControl(''),  lastName:  new FormControl(''),  // Signal-based control coexists naturally  nickname: new SignalFormControl('', (field) => {    required(field);    minLength(field, 2);  }),});// legacyForm.getRawValue()// → { firstName: '', lastName: '', nickname: '' }// nickname behaves exactly like a regular FormControl from the parent's perspective

Paso 4: añadir ngNoCva al mezclar ambas API en el mismo elemento. Cuando [formField] se usa en una entrada que también está dentro de un [formGroup], ambas API intentan controlar el mismo elemento. ngNoCva le dice a Angular que omita el CVA y deje que Signal Forms tome el control.

⚠️ La directiva ngNoCva: solo necesitas esto al mezclar [formField] con [formGroup], formControlName, o ngModel en el mismo elemento. Si estás usando [formField] de forma independiente o en un componente FormValueControl, no hay conflicto.


Cuándo usar Signal Forms (y cuándo no)

✅ Opta por Signal Forms cuando

  • Escribas cualquier formulario nuevo desde cero: esta es la opción predeterminada ahora

  • Construyas controles de formulario personalizados: reemplaza CVA por completo

  • Los datos del formulario provienen de una API: linkedSignal + httpResource se componen naturalmente

  • Se requiera validación asíncrona: debouncing integrado, sin configuración de Observable

  • Exista validación entre campos: lee las signals de los hermanos directamente en el esquema

  • Quieres un .value() tipado sin .getRawValue() o coerción de nulos

  • Estás en una aplicación zoneless: las Signals son la primitiva correcta de principio a fin

Cuándo usar Signal Forms

Escribir cualquier formulario nuevo desde cero (opción predeterminada)

Construir controles de formulario personalizados (reemplaza totalmente CVA)

Datos de formulario obtenidos de una API (natural con linkedSignal + httpResource)

Validación asíncrona necesaria (debouncing integrado, sin configuración de Observable)

Validación entre campos soportada mediante signals de hermanos en el esquema

Cuándo mantener Reactive Forms

Formularios existentes que funcionan sin errores

Lógica compleja de FormGroup con gestión de estado personalizada

Librerías CVA de terceros aún no actualizadas

El equipo necesita tiempo para ganar intuición antes de migrar

Paneles de administración simples o pantallas de configuración sin problemas

→ Mantén Reactive Forms cuando

  • El formulario ya funciona y no hay errores que corregir

  • Lógica de FormGroup muy personalizada con gestión de estado compleja

  • Librerías CVA de terceros que aún no se han actualizado

  • Tu equipo necesita tiempo para desarrollar intuición: no apresures una migración masiva

  • Paneles de administración y pantallas de configuración con lógica simple y sin problemas


En conclusión

Signal Forms no es una nueva forma de pensar sobre los formularios. Son los mismos conceptos (validación, estado, enlace, envío) construidos sobre la primitiva correcta. Las Signals siempre iban a ser la base de la reactividad de Angular; los formularios eran solo la última gran superficie en ponerse al día.

El impacto práctico es inmediato en dos lugares:

Controles personalizados: si tienes un sistema de diseño con 10 entradas personalizadas, cada una puede perder 30-40 líneas de boilerplate la próxima vez que la toques.

Integración formulario-API: el patrón httpResource + linkedSignal elimina toda una categoría de llamadas manuales a patchValue() y gestión de suscripciones que plagaban los formularios de edición en cada base de código de Angular.

La historia de la migración es genuinamente buena. Angular 22 no te obliga: ControlValueAccessor sigue funcionando, FormGroup sigue funcionando, y las dos API pueden coexistir en el mismo formulario. El momento adecuado para cambiar es el próximo formulario nuevo que escribas, no un sprint de migración programado.

La guía de decisión de una frase: ¿Formulario nuevo? Usa Signal Forms: form(), model(), FormValueControl. ¿Formulario existente que funciona? Déjalo.¿Formulario existente que vas a tocar de todos modos? Migra primero los controles personalizados, luego considera el modelo de formulario si vale la pena el diff.

Los mejores formularios de Angular no se sienten como formularios de Angular. Se sienten como datos.


Fuentes

  • angular.dev/guide/forms/signals/overview — Guía oficial de Signal Forms de Angular: form(), [formField], validadores, validación asíncrona, formularios anidados.

  • angular.dev/essentials/signal-forms — Descripción general de elementos esenciales, interfaz FormValueControl, controles personalizados sin ControlValueAccessor.

  • angular.dev/guide/forms/signals/migration — Guía oficial de migración desde Reactive Forms, puente compatForm, estrategia incremental.

  • Santosh Yadav — santoshyadav.dev (16 de junio de 2026) — "Angular 22: The Signals Are Strong" — ngNoCva, SignalFormControl, OnPush como nuevo predeterminado, linkedSignal opción de conjunto personalizado.

  • Angular.love (26 de mayo de 2026) — "Signal Forms in Angular 21: Complete Guide" — compatForm, modelo de validación, comparación CVA→FormValueControl.

  • ANGULARarchitects.io (junio de 2026) — "All About Angular's New Signal Forms" — validación personalizada, esquemas, formularios anidados, form arrays, debouncing.

  • Brian Treese — briantree.se (26 de marzo de 2026) — "Angular 22: Mix Signal Forms and Reactive Forms Seamlessly" — FormValueControl migración, estrategia incremental.

  • LogRocket Blog (27 de marzo de 2026) — "Signal Forms: Angular's best quality of life update in years."

  • Rajat / Substack (junio de 2026) — "Signal Forms Are Finally Production-Ready in Angular 22" — form() API, httpResource + linkedSignal patrón, validación asíncrona.

  • Angular_with_Awais / Medium (mayo de 2026) — "Angular v22 Signal Forms: Build Custom Controls Without ControlValueAccessor."


Los ejemplos de código reflejan la API estable de Signal Forms de Angular 22 lanzada el 3 de junio de 2026. ControlValueAccessor no está obsoleto y sigue funcionando en Angular 22. Consulta siempre angular.dev/guide/forms/signals para obtener la API estable actual.