Angular Signal Forms: un cambio de paradigma

Publicado por Saúl Moro el

FrontendFrontAngularAngular Signal Forms

"El formulario es el modelo."

Miré ese formulario. Había pasado una hora añadiendo campos de huésped al formulario de reservas. Cinco minutos añadiendo el campo. Una hora montando el FormArray, la lógica de add/remove, las validaciones, sincronizando el modelo, gestionando índices, escribiendo todos los toSignal() y toObservable() para que funcionara con el resto del flujo basado en Signals.

He construido cientos de Angular Reactive Forms. Multi-step wizards. Flujos de reserva. Arrays dinámicos con validación anidada. Formularios de datos con secciones condicionales. Formularios que podría escribir con los ojos cerrados.

Entonces Angular pasó a Signals. Los componentes se volvieron Signals. El estado de la app pasó a ser Signals. Los nuevos templates con @if y @for. Los computed(). Los effect(). Los httpResource(). Todo Signals. Todo menos los formularios.

Ahí seguían los FormGroup y FormControl. Con sus Observables. Con valueChanges. Con suscripciones que limpiar. Yo, en el medio, traduciendo entre dos mundos.

El problema real

toSignal() para leer valores del form. toObservable() para mezclar datos del form con Signals del estado. Convertir. Suscribir. Limpiar. Repetir. Dos paradigmas de reactividad peleando en el mismo componente. No era insostenible. Era molesto.

Entonces llegó Angular 21. Zoneless por defecto. Los nuevos Signal Forms en la misma versión.

No era casualidad.

Ya había probado zoneless. Los Reactive Forms pedían parches constantes. La validación async no disparaba change detection. Los cambios de patchValue() tampoco. Inyectaba ChangeDetectorRef en cada componente con formulario. Llamaba a markForCheck() tras cada cambio. Limpiaba suscripciones con takeUntilDestroyed().

Este no es un problema aislado de un proyecto. Es en los fundamentos del framework.

Los Reactive Forms se diseñaron para Zone.js. Nacieron antes de los Signals. Su modelo de reactividad asume que Zone.js detectará cambios automáticamente. En un mundo zoneless, pierden ese soporte. En una app basada en Signals, introduces un segundo sistema de reactividad que no se integra nativamente. Estás forzando dos paradigmas diferentes a convivir. Puedes hacerlo funcionar con adaptadores (toSignal, toObservable) y parches manuales (markForCheck), pero vas a contrapié del framework.

Los Signal Forms no eran una alternativa.

Eran inevitables.

Los Signal Forms están en developer preview. Durante el lanzamiento de Signals, el equipo de Angular mantuvo estabilidad de API. Hubo pocos breaking changes, todos menores o irrelevantes en el día a día. Espera lo mismo aquí.

Si eres nuevo en zoneless, la guía oficial lo explica.

Tres días rehaciendo todo

Pasé tres días rehaciendo la demo de reservas de la empresa con Signal Forms. Solo para probar. Para ver si realmente cambiaba cómo construimos formularios o si era otra feature más. Al primer día dejé de buscar FormGroup y FormControl mentalmente. A las pocas horas estaba borrando más código del que escribía.

TL;DR: La diferencia en 20 líneas

import { signal } from '@angular/core';
import { form, required, min } from '@angular/forms/signals';

const model = signal({ destination: '', checkIn: '', checkOut: '', guests: 1 });

const f = form(model, (p) => {
  required(p.destination);
  required(p.checkIn);
  required(p.checkOut);
  min(p.guests, 1);
});
<input [field]="f.destination" placeholder="Where to?" />
<button type="submit" [disabled]="!f().valid()">Search</button>

Tu modelo es la fuente de la verdad y define el schema. El formulario es una vista reactiva sobre ese Signal. Los errores viven en el schema. Con tipado automático basado en el modelo. Fine grained gracias a los Signals.

¿Para ti?

  • Quieres zoneless sin parches.
  • Necesitas validación o estado declarativo.
  • Estás cansado de sincronizar form y modelo.
  • Vas a añadir formularios nuevos en una app con Signals.

Contenido: Empezando simple · Cambio de paradigma · Validación · Qué cambió en mi cabeza · Equivalencias · Patrones · Testing · Gotchas · Cuándo usarlo · Mirando hacia delante


Empezando simple

Abrí el formulario de búsqueda de hotel. Cuatro campos. Destination, check-in, check-out, guests. En Reactive Forms: 40 líneas de setup. Constructor de FormGroup. Instancias de FormControl. Array de validators. Getters para acceder a los controles. Bindings en el template.

Esta es la versión completa con Signal Forms:

import { signal } from '@angular/core';
import { form, required, min } from '@angular/forms/signals';

const searchModel = signal({
  destination: '',
  checkIn: '',
  checkOut: '',
  guests: 1
});

const searchForm = form(searchModel, (p) => {
  required(p.destination, { message: 'Destination is required' });
  required(p.checkIn, { message: 'Check-in date is required' });
  required(p.checkOut, { message: 'Check-out date is required' });
  required(p.guests, { message: 'Number of guests is required' });
  min(p.guests, 1);
});

Me quedé mirando un minuto. ¿Dónde está el FormGroup? ¿Los FormControl? Busqué la parte donde instancio los controles.

No está.

Defines el modelo como un Signal. Declaras las reglas de validación en el schema. Ya está. El formulario es una vista reactiva sobre el Signal. Tipado y schema automático basado en el modelo. Fine-grained reactivity gracias a los Signals.

El template:

<input type="text" [field]="searchForm.destination" placeholder="Where to?" />
@if (searchForm.destination().touched() && searchForm.destination().invalid()) {
  @for (error of searchForm.destination().errors(); track error.kind) {
    <p>{{ error.message }}</p>
  }
}

Una sola directiva. Estado de validación basado en Signals. Mensajes de error en el schema, no mezclados en el template. De 40 líneas a 15. Y tipado de principio a fin.

El cambio de paradigma

Tardé tres formularios en entenderlo de verdad. En los Reactive Forms, FormControl es la fuente de la verdad. Lo sincronizas con tu modelo a mano o con valueChanges. Dos fuentes que alinear. En los Signal Forms, el Signal del modelo ES la fuente de la verdad. El formulario es una vista reactiva sobre ese Signal.

Una sola fuente.

<!-- El usuario escribe en el input (Ejemplo: "Madrid") -->
<input [field]="searchForm.destination" placeholder="Where to?" />
// El form lo refleja al instante
searchForm.destination().value(); // 'Madrid'

Unas horas después implementaba "cambiar fechas" en el flujo de reserva. El usuario hace clic, el backend devuelve fechas nuevas, el formulario debe actualizarse. Con Reactive Forms, he programado esto cientos de veces. Traer datos. Llamar a patchValue(). Asegurar sincronía con el modelo. Rezar por no olvidar un campo.

Empecé a escribir patchValue.

Paré. Espera.

Actualicé el Signal:

bookingModel.update(m => ({ ...m, checkIn: newDates.checkIn, checkOut: newDates.checkOut }));

El formulario lo reflejó al instante.

🤯

Ahí hizo click.

No hay nada que sincronizar. El formulario ES el modelo.

Llevaba años escribiendo código de sincronización. Lo había escrito con cuidado. Testeado. Revisado en PRs. Código que pensaba que era necesario. Código que nunca debió existir.

Validación entre campos

El check-out debe ser posterior a check-in. Lógica básica de reservas. Debería ser simple. Con Reactive Forms, escribes un validador custom. Lo defines en el array de validators del FormControl o con setValidators(). Recuerdas disparar validación cuando cambia cualquiera de las dos fechas. Cruzas los dedos. Con Signal Forms:

const bookingForm = form(bookingModel, (p) => {
  required(p.checkIn);
  required(p.checkOut);

  validate(p.checkOut, ({ value, valueOf }) => {
    const checkIn = new Date(valueOf(p.checkIn));
    const checkOut = new Date(value());

    if (checkOut <= checkIn) {
      return {
        kind: 'invalid_date_range',
        message: 'Check-out must be after check-in'
      };
    }
    return null;
  });
});

Si cambia cualquiera, el validador se ejecuta de nuevo. Eso es todo. La reactividad de Signals lo hace solo. Sin triggers manuales. Sin updateValueAndValidity(). Sin recordar qué campos afectan a qué validaciones.

Validación condicional

La reserva tenía opción de traslado desde el aeropuerto. Si el usuario la marca, aparecen tres campos: número de vuelo, hora de llegada, terminal. Si la desmarca, esos campos no deberían validarse. Con Reactive Forms, esto pide lógica imperativa. Observar el checkbox. Cuando cambia, llamar a setValidators() o clearValidators(). Llamar a updateValueAndValidity(). Gestionar el timing. Con Signal Forms es declarativo:

const reservationForm = form(reservationModel, (p) => {
  required(p.email);
  required(p.phone);

  // Solo validar el traslado si se solicita
  applyWhen(
    p.airportTransfer,
    ({ valueOf }) => valueOf(p.needsTransfer),
    (transferPath) => {
      required(transferPath.flightNumber);
      required(transferPath.arrivalTime);
      required(transferPath.terminal);
    }
  );
});

El usuario marca la casilla: aparece la validación. La desmarca: desaparece. Sin setValidators() imperativo. Sin updateValueAndValidity(). El schema es declarativo. La validación reacciona a los datos.

Llevaba años llamando a APIs imperativas para algo que debería ser declarativo desde el principio.

Estado de los campos

El wizard de reserva mostraba u ocultaba campos según el tipo de habitación. Las habitaciones standard pedían preferencia de fumador. Las suites pedían planta preferida y configuración de cama. Con Reactive Forms, gestionar esto es imperativo. Buscar el campo. Evaluar la condición. Llamar a disable() o enable(). Recordar hacerlo cada vez que cambia el tipo de habitación. Te olvidas una llamada y publicas un bug. Con Signal Forms, declaras el estado en el schema:

const reservationForm = form(reservationModel, (p) => {
  // Siempre deshabilitado (número de confirmación)
  disabled(p.confirmationNumber);

  // Condicionalmente deshabilitado
  disabled(p.roomPreferences, ({ valueOf }) => valueOf(p.roomType) === 'standard');

  // Solo lectura para reservas confirmadas
  readonly(p.checkIn, ({ valueOf }) => valueOf(p.status) === 'confirmed');

  // Oculto a menos que sea miembro "loyalty"
  hidden(p.loyaltyNumber, ({ valueOf }) => !valueOf(p.isLoyaltyMember));

  // Debounce validation (disponible pronto)
  debounce(p.destination, 300);
});

La directiva Field respeta estos estados. Si cambia la condición, se actualiza el estado del campo. Secciones enteras aparecen o desaparecen según respuestas previas. El schema declara el cuándo. Los Signals hacen el resto.

Validación async, simple

Temía esta parte.

Los Observables son la forma de trabajar con asincronía. Los Signals son síncronos.

¿Cómo validas contra un servidor con un Signal?

La búsqueda necesitaba comprobar disponibilidad en tiempo real. El usuario escribe un destino, la API verifica si hay hoteles, el form muestra el resultado.

Con Reactive Forms ya había escrito validadores async. Debounce para no saturar el servidor. Cancelación de peticiones obsoletas. Gestión del estado pending. No era trivial, pero funcionaba.

¿Cómo iba a funcionar con Signals?

Angular te da dos funciones helper.

Primero, validateAsync:

import { validateAsync } from '@angular/forms/signals';

const searchForm = form(searchModel, (p) => {
  validateAsync(p.destination, {
    params: ({ value }) => ({ destination: value() }),
    factory: (params) =>
      resource({
        request: params,
        loader: ({ request }) => this.destinationService.isAvailable(request.destination)
      }),
    onSuccess: (result) => {
      if (!result?.available) {
        return {
          kind: 'destination_unavailable',
          message: 'No hotels available in this destination'
        };
      }
      return null;
    },
    onError: () => ({ kind: 'network', message: 'Validation service unavailable' })
  });
});

Luego, validateHttp:

import { validateHttp } from '@angular/forms/signals';

const searchForm = form(searchModel, (p) => {
  validateHttp(p.destination, {
    request: ({ value }) =>
      `https://api.hotels.com/destinations/validate?destination=${encodeURIComponent(value())}`,
    onSuccess: (result, ctx) => {
      if (!result?.available) {
        return {
          kind: 'destination_unavailable',
          message: 'No hotels available in this destination'
        };
      }
      return null;
    },
    onError: (error, ctx) => {
      return { kind: 'network', message: 'Validation service unavailable' };
    }
  });
});

Mientras valida, searchForm.destination().pending() es true. Muestras un spinner. Fin. Angular omite validadores async si fallan los sync. Menos llamadas innecesarias. Si el campo cambia durante la validación, la petición se cancela automáticamente. El estado pending viene integrado. No escribes nada de eso. Ya está.

La parte que temía resultó ser simple.

Mejora aún más con debounce() en el schema.

Angular está añadiendo debounce() al schema (PR pendiente). 300 milisegundos en el schema. Eso es todo:

const searchForm = form(searchModel, (p) => {
  debounce(p.destination, 300);

  validateHttp(p.destination, {
    request: ({ value }) => `https://api.hotels.com/destinations/validate?destination=${encodeURIComponent(value())}`,
    onSuccess: (result) => {
      if (!result?.available) {
        return { kind: 'destination_unavailable', message: 'No hotels available' };
      }
      return null;
    }
  });
});

Mientras el usuario escribe, se va disparando este debounce para no llamar al servidor innecesariamente. Una sola llamada al servidor cuando termina de escribir.

Qué cambió en mi cabeza

De dos fuentes a una.
De sincronizar a declarar.
De imperativo a reactivo.

Busqué ChangeDetectorRef en el código de reservas. Siete componentes. Todos con formularios. Todos con el mismo patrón: inyectar ChangeDetectorRef, llamar a markForCheck() tras patchValue(), limpiar suscripciones. Busqué toSignal() y toObservable(). Quince usos. Convertir del form al resto de la app. Convertir del estado de la app al form. Pegamento entre dos mundos.

Los Reactive Forms en una app basada en Signals necesitan parches constantes. Los Signal Forms trabajan con la arquitectura de Angular. El estado de validación es un Signal. La UI se actualiza sola. Sin markForCheck(). Sin limpiar suscripciones. Sin convertir entre paradigmas. Sin adaptadores.

El modelo mental se simplifica

Lo más duro de Reactive Forms era la danza de sincronía. El formulario de reserva tenía los datos en un servicio. El form tenía su propio estado en el componente. Suscripciones a valueChanges para mantenerlos alineados. Usuario cambia fechas en el form. Actualizo el servicio. Usuario navega atrás desde otra ruta. El servicio cambia. Llamo a patchValue para actualizar el form.

Dos fuentes de verdad. Dos sistemas de reactividad. Observables aquí. Signals allá. toSignal() y toObservable() como pegamento.

Con Signal Forms, eso desaparece.

El Signal es la fuente de la verdad. El form es una vista reactiva sobre ese Signal. Navego entre pasos. El form refleja el Signal. Sin código de sincronía. Sin patchValue. Nada.

Borré 80 líneas del flujo de reserva. Código que solo existía para mantener dos sistemas sincronizados.

El tipado con TypeScript

TypeScript conoce tu schema completo. Las rutas de campos están tipadas. Cambias la interfaz del modelo. El schema marca los errores al momento. No hay sorpresas en runtime por typos en form.get('fieldName'). Renombré guestName a un objeto hijo primaryGuest.name en el modelo de reserva. TypeScript marcó siete referencias en tres archivos que seguían usando el nombre antiguo. Con Reactive Forms, habría tenido que cambiar el FormControl a un FormGroup, rellenar el grupo con los campos del modelo, y esperar no olvidarme nada.

Equivalencias rápidas

Si vienes de Reactive Forms, esta tabla te ayuda a traducir mentalmente:

Reactive Forms Signal Forms
FormGroup guarda estado Modelo signal({...}) + form(model, schema)
Instancias FormControl Rutas de propiedades vía form.property + directiva [field]
Array de validators required, min, pattern, validate() custom en el schema
Suscripciones valueChanges Signals: lee value(), usar computed() si hace falta
toSignal()/toObservable() para integrar Nativo: todo es Signal desde el inicio
patchValue()/setValue() model.update(...) o model.set(...)
Gestión de FormArray Signal de array + applyEach()
ControlValueAccessor Implementa FormValueControl<T> (simple Signal value)
setValidators() + updateValueAndValidity() Declarativo; se re-evalúa solo al cambiar dependencias
Clases CSS ng-invalid/ng-touched Enlaza clases con Signals: [class.error]="field().invalid() && field().touched()"
Async validators con Observables validateAsync() o validateHttp() con cancelación/pending integrados

La diferencia más grande: dejas de pensar en controles y empiezas a pensar en el modelo.


Patrones que necesitas

Schemas reutilizables

La reserva pedía info de huésped en tres sitios. Huésped principal. Huésped adicional. Contacto de emergencia. Misma validación en los tres: nombre, email, teléfono.

No iba a copiar/pegar tres veces.

import { schema, apply } from '@angular/forms/signals';

const guestInfoSchema = schema<GuestInfo>((p) => {
  required(p.firstName);
  required(p.lastName);
  required(p.email);
  email(p.email);
  required(p.phone);
  pattern(p.phone, /^\+?[1-9]\d{1,14}$/);
});

const reservationForm = form(reservationModel, (p) => {
  required(p.checkIn);
  required(p.checkOut);

  // Aplicar la validación de huésped a múltiples viajeros
  apply(p.primaryGuest, guestInfoSchema);
  apply(p.additionalGuest, guestInfoSchema);
});

Un solo schema. Aplícalo donde haga falta. Tipado.

También pueden ser condicionales:

const reservationForm = form(model, (p) => {
  // Validación diferente por tipo de habitación
  applyWhenValue(p.roomType, 'suite', (suitePath) => {
    required(suitePath.preferredFloor);
    required(suitePath.bedConfiguration);
  });

  applyWhenValue(p.roomType, 'standard', (standardPath) => {
    required(standardPath.smokingPreference);
  });
});

El schema cambia con los datos. Sin lógica imperativa. Declarativo hasta el final.

Integración StandardSchema

Si ya usas Zod, Yup o Valibot, no reescribas todo. O si quieres validación probada en miles de proyectos, estas librerías te dan ese nivel de confianza.

Signal Forms soporta standard-schema con validateStandardSchema:

import { z } from 'zod';
import { validateStandardSchema } from '@angular/forms/signals';

const ReservationSchema = z
  .object({
    destination: z.string().min(2, 'Destination too short'),
    checkIn: z.string().refine((val) => new Date(val) >= new Date(), 'Must be future date'),
    checkOut: z.string(),
    guests: z.number().min(1).max(10)
  })
  .refine((data) => {
    return new Date(data.checkOut) > new Date(data.checkIn);
  }, 'Check-out must be after check-in');

const reservationForm = form(reservationModel, (p) => {
  validateStandardSchema(p, ReservationSchema);
});

Ejecuta el schema, mapea errores al formato de Angular y funciona con cualquier librería standard-schema. Zod, Yup, Valibot. La que uses.

También por campos:

const destinationSchema = z.string().min(2, 'Too short');

const searchForm = form(searchModel, (p) => {
  validateStandardSchema(p.destination, destinationSchema);
  validateStandardSchema(p.guests, z.number().min(1).max(10));
});

Me ahorró horas. Sin mapeo manual de errores. Sin reescribir validación. Solo conectar schemas existentes.

Arrays dinámicos

Reservas de grupo. Varios viajeros. Cada uno con sus datos. Añadir y quitar. Validar todos.

En los Reactive Forms, esto significa pelearse con los FormArray. Crear controles. Push. Quitarlos. Índices. Mantener la validación sincronizada.

Con los Signal Forms:

const guestsSignal = signal([{ firstName: '', lastName: '', age: 0 }]);

const guestsForm = form(guestsSignal, (p) => {
  applyEach(p, (guestPath) => {
    required(guestPath.firstName);
    required(guestPath.lastName);
    required(guestPath.age);
    min(guestPath.age, 0);
  });
});

El template itera como siempre:

@for (guest of guestsForm; track $index) {
<div>
  <input [field]="guest.firstName" placeholder="First name" />
  <input [field]="guest.lastName" placeholder="Last name" />
  <input [field]="guest.age" type="number" placeholder="Age" />
  <button (click)="removeGuest($index)">Remove</button>
</div>
}

<button (click)="addGuest()">Add Guest</button>

Añadir y quitar:

addGuest() {
  guestsSignal.update(arr => [...arr, { firstName: '', lastName: '', age: 0 }]);
}

removeGuest(index: number) {
  guestsSignal.update(arr => arr.filter((_, i) => i !== index));
}

Sin gestionar FormArray. Sin pushear ni quitar controles. Actualiza el Signal. Los campos del form se actualizan automáticamente.

Envío del formulario

Al final hay que enviar. Los errores del servidor deben caer en su campo. El estado de envío debe deshabilitar el botón. Hay que marcar campos como touched para mostrar errores. submit() lo cubre:

import { submit } from '@angular/forms/signals';

async onSubmit(event: Event) {
  event.preventDefault();

  await submit(this.reservationForm, async (form) => {
    const result = await this.bookingService.createReservation(form.value());

    // Mapear errores del servidor a campos específicos
    if (result.errorCode === 'ROOM_UNAVAILABLE') {
      return [{
        field: form.checkIn,
        error: { kind: 'server', message: 'No rooms available for these dates' }
      }];
    }

    if (result.errorCode === 'INVALID_PAYMENT') {
      return [{
        field: form.paymentInfo.cardNumber,
        error: { kind: 'server', message: 'Payment declined' }
      }];
    }

    // Sin errores
    return undefined;
  });
}

submit() recibe tu form y una acción. La acción hace el envío y devuelve errores del servidor mapeados a campos.

Los errores aparecen solos:

reservationForm.checkIn().errors()
// Devuelve: [{kind: 'server', message: 'No rooms available for these dates'}]

El template reacciona automáticamente al estado de envío:

<button type="submit" [disabled]="reservationForm().submitting()">
  @if (reservationForm().submitting()) { Processing reservation... } @else { Book Now }
</button>

<!-- Los errores aparecen como cualquier otro error de validación -->
@if (reservationForm.checkIn().errors().length) {
  @for (error of reservationForm.checkIn().errors(); track error.kind) {
    <p class="error">{{ error.message }}</p>
  }
}

Composición por componentes

La reserva se fue a 300 líneas. Demasiado. Tocaba dividir.

Los Signal Forms usan FieldTree<T> para pasar subárboles del formulario:

// Componente padre
@Component({
  imports: [GuestInfoComponent],
  template: `
    <app-guest-info [guestField]="reservationForm.primaryGuest" label="Primary Guest" />
    <app-guest-info [guestField]="reservationForm.additionalGuest" label="Additional Guest" />
  `
})
export class ReservationFormComponent {
  reservationForm = form(
    signal({
      checkIn: '',
      checkOut: '',
      primaryGuest: { firstName: '', lastName: '', email: '', phone: '' },
      additionalGuest: { firstName: '', lastName: '', email: '', phone: '' }
    })
  );
}
// Componente hijo
@Component({
  selector: 'app-guest-info',
  imports: [Field],
  template: `
    <h3>{{ label() }}</h3>
    <input [field]="guestField().firstName" placeholder="First name" />
    <input [field]="guestField().lastName" placeholder="Last name" />
    <input [field]="guestField().email" placeholder="Email" />
    <input [field]="guestField().phone" placeholder="Phone" />
  `
})
export class GuestInfoComponent {
  guestField = input.required<FieldTree<GuestInfo>>();
  label = input.required<string>();
}

El hijo recibe una referencia reactiva a un subárbol. Los cambios fluyen. Sin prop drilling. Sin emitir eventos por cada campo.

Controles de formulario personalizados

El contador de huéspedes pedía botones + y −.

En Reactive Forms, esto significa ControlValueAccessor. writeValue(), registerOnChange(), registerOnTouched(). Un comedero de cabeza cada vez.

Con los Signal Forms la interfaz es más simple:

interface FormValueControl<T> {
  value: ModelSignal<T>;
  disabled?: InputSignal<boolean>;
  errors?: InputSignal<readonly ValidationError[]>;
}

El contador:

@Component({
  template: `
    <div class="guest-counter">
      <button (click)="decrement()" [disabled]="disabled() || value() <= 1">-</button>
      <span>{{ value() }} {{ value() === 1 ? 'guest' : 'guests' }}</span>
      <button (click)="increment()" [disabled]="disabled() || value() >= 10">+</button>
    </div>
  `
})
export class GuestCounterComponent implements FormValueControl<number> {
  value = model(1);
  disabled = input(false);
  errors = input<readonly ValidationError[]>([]);

  increment() {
    this.value.update((v) => Math.min(10, v + 1));
  }

  decrement() {
    this.value.update((v) => Math.max(1, v - 1));
  }
}

Uso:

<app-guest-counter [field]="searchForm.guests" />

La directiva Field sincroniza por ti. Sin callbacks. Sin registerOnChange. Solo un Signal.

Persistencia del estado

El flujo de reserva tenía tres pasos. Búsqueda. Datos de huésped. Pago. El usuario rellena búsqueda, navega a datos, vuelve atrás. El estado debe persistir.

Tu modelo es solo un Signal. Úsalo con cualquier solución de estado. @ngrx/signals, tu store de Signals, un servicio simple. Lo que encaje.

Ejemplo con un servicio:

import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class BookingStateService {
  readonly booking = signal<BookingState>({
    destination: '',
    checkIn: '',
    checkOut: '',
    guests: 1,
    primaryGuest: { firstName: '', lastName: '', email: '', phone: '' },
    paymentInfo: { cardNumber: '', expiryDate: '', cvv: '' }
  });

  updateBooking(update: Partial<BookingState>) {
    this.booking.update((state) => ({ ...state, ...update }));
  }

  resetBooking() {
    this.booking.set({
      destination: '',
      checkIn: '',
      checkOut: '',
      guests: 1,
      primaryGuest: { firstName: '', lastName: '', email: '', phone: '' },
      paymentInfo: { cardNumber: '', expiryDate: '', cvv: '' }
    });
  }
}

Los componentes consumen el Signal:

// Paso 1: Componente de búsqueda
@Component({
  imports: [Field],
  template: `
    <input [field]="searchForm.destination" />
    <input [field]="searchForm.checkIn" type="date" />
    <input [field]="searchForm.checkOut" type="date" />
    <app-guest-counter [field]="searchForm.guests" />
    <button (click)="nextStep()">Continue</button>
  `
})
export class SearchStepComponent {
  private bookingState = inject(BookingStateService);
  private router = inject(Router);

  searchForm = form(this.bookingState.booking, (p) => {
    required(p.destination);
    required(p.checkIn);
    required(p.checkOut);
    min(p.guests, 1);
  });

  nextStep() {
    if (this.searchForm().valid()) {
      this.router.navigate(['/booking/guest-info']);
    }
  }
}

Navega entre pasos. El estado persiste. El Signal es tu fuente de verdad en todo el flujo. Nada de sincronizar estado entre pasos. El botón Back funciona sin extra.

Una sola fuente a través de varios componentes.

Para persistir entre sesiones, añade localStorage:

@Injectable({ providedIn: 'root' })
export class BookingStateService {
  private readonly STORAGE_KEY = 'booking-state';

  private booking = signal(this.loadFromStorage());

  constructor() {
    // Auto-guardar en storage cuando cambian los datos
    effect(() => {
      localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.booking()));
    });
  }

  private loadFromStorage(): BookingState {
    const stored = localStorage.getItem(this.STORAGE_KEY);
    return stored ? JSON.parse(stored) : this.getDefaultState();
  }

  private getDefaultState(): BookingState {
    return {
      destination: '',
      checkIn: '',
      checkOut: '',
      guests: 1,
      primaryGuest: { firstName: '', lastName: '', email: '', phone: '' },
      paymentInfo: { cardNumber: '', expiryDate: '', cvv: '' }
    };
  }
}

effect() guarda en localStorage cuando cambia el estado. Recarga la página. El form sigue ahí.

Es un ejemplo de un patrón con servicio. La idea funciona igual con @ngrx/signals u otra solución basada en Signals.

Tu modelo es un Signal. Se enchufa a lo que uses.

Formularios desde JSON (data-driven forms)

Hay un patrón más que no he explorado aún: formularios desde JSON. Como Formly, pero con Signal Forms. Alex Rickabaugh del equipo Angular publicó un PoC de data-driven forms que genera formularios completos desde descriptores. Un componente renderiza los campos. El backend envía el schema. El form se construye solo.

Si construyes aplicaciones data-driven, vale la pena explorarlo.

Testing con Signal Forms

Si haces tests desde la perspectiva del usuario, no cambia nada.

// Testing desde perspectiva de usuario - funciona igual
await userEvent.type(screen.getByLabelText('Destination'), 'Barcelona');
await userEvent.click(screen.getByRole('button', { name: 'Search' }));
expect(screen.getByText('Available hotels')).toBeInTheDocument();

Estos tests siguen pasando. Los Signal Forms no alteran el comportamiento visible.

Si tus tests miran clases CSS de estado, toca actualizar:

// Esto se rompe - estas clases no existen en Signal Forms
const invalidInput = fixture.nativeElement.querySelector('.ng-invalid.ng-touched');
expect(invalidInput).toBeTruthy();

Los Signal Forms no aplican ng-valid, ng-invalid, ng-touched ni ng-dirty. Tests basados en esas clases fallan. Testea por rol/label/text o añade tus propias clases.

Angular Testing Library encaja bien con este estilo.

Testear la lógica del formulario directamente

Los Signal Forms centralizan la lógica en TypeScript.

Haz unit tests sin renderizar:

const model = signal({
  destination: '',
  checkIn: '',
  checkOut: '',
  guests: 1
});

const searchForm = form(model, (p) => {
  required(p.destination);
  required(p.checkIn);
  required(p.checkOut);
  min(p.guests, 1);
});

// Testear validación sin DOM
searchForm.destination().value.set('');
expect(searchForm.destination().invalid()).toBe(true);

searchForm.destination().value.set('Barcelona');
expect(searchForm.destination().valid()).toBe(true);

searchForm.guests().value.set(0);
expect(searchForm.guests().invalid()).toBe(true);

searchForm.guests().value.set(2);
expect(searchForm.guests().valid()).toBe(true);

Sin renderizar componentes. Sin fixture. Solo manipulas Signals y haces asserts.

Más rápido y claro que con los Reactive Forms.

Cuidado con esto

Evítate quebraderos de cabeza. Esto fue lo que me ocurrió.

Las clases CSS desaparecen. Los Reactive Forms añaden ng-valid, ng-invalid, ng-touched, ng-dirty. Los Signal Forms no. Tus selectores actuales de CSS se romperán. Usa bindings basados en Signals:

<input
  [field]="searchForm.destination"
  [class.error]="searchForm.destination().invalid() && searchForm.destination().touched()"
/>

Reset. reset() solo limpia estados (touched, dirty, errors) y sus descendientes. Los valores se resetean a mano:

searchForm().reset();
searchForm().value.set({ destination: '', checkIn: '', checkOut: '', guests: 1 });

Atributos de validación auto-aplicados. maxlength trunca silenciosamente lo pegado. El usuario pega un texto largo. Se corta. Sin aviso. Sin error. Considera validación custom si esto no es lo que buscas.

Sin tooling de migración. No hay schematics para migrar de Reactive Forms a Signal Forms. Refactor manual. Probablemente intencional por el equipo Angular. Creo que es una migración difícil de automatizar.

Todo es un Signal. searchForm.destination().value(). Paréntesis por todas partes. Te acostumbras.

Soluciones rápidas

  • ¿Faltan clases CSS? Enlaza con Signals: [class.error]="field().invalid() && field().touched()".
  • ¿Reset completo? Llama a form().reset() y luego establece los valores del modelo.
  • ¿Truncado silencioso por maxlength? Prefiere validador + mensaje antes que el atributo.
  • ¿Validación condicional que no salta? Asegúrate de usar valueOf(dep) para trackear dependencias.
  • ¿Parece que no se lanza la validación async? Primero corren los validadores sync. Si pasan, luego los async. También puedes condicionar en params.
  • ¿Arrays desincronizados? Usa claves estables (ids) en @for en lugar de $index si pueden reordenarse.

Cuándo usarlo

Si empiezas un proyecto nuevo, empieza con Signal Forms. Si tienes una app existente, depende de tu tolerancia al riesgo y la situación del proyecto. Angular 21 puso zoneless por defecto. No es casualidad que los Signal Forms llegaran en la misma versión. Son la solución nativa para un framework que es Signals de arriba abajo. Los Reactive Forms nacieron para Zone.js y Observables. Siguen funcionando, pero con capas de apaños. Vas cuesta arriba.

La dirección es clara. Los cambios de API que sufrirán los Signal Forms serán refinamiento, no reescritura. El historial del equipo Angular con Signals lo sugiere: casi ningún breaking change durante el lanzamiento de Signals. ¿Migrar formularios existentes? Espera a estabilidad de API. Pero aprende los Signal Forms ya. Con Angular zoneless por defecto y Signals por todas partes, los Reactive Forms pasarán al legacy.

La buena noticia: no tienes que migrar todo de golpe. Signal Forms es una librería independiente de Reactive Forms. Pueden convivir en la misma app. Usa Reactive Forms en código existente. Signal Forms para funcionalidad nueva. Migración incremental. Sin big bang. Sin riesgo.

Espera para migrar si:

  • Dependéis fuerte de clases tipo ng-invalid/ng-touched en CSS
  • El equipo está optimizado a FormGroup y patrones RxJS

Empieza a usar los Signal Forms si:

  • Tu app usa Signals para componentes, estado o templates
  • Funcionalidad nueva en una app existente (pueden convivir con Reactive Forms)
  • Quieres formularios model-first, guiados por tipos
  • Cansado de convertir entre Observables y Signals

El riesgo de adopción temprana es menor que en otras previews.

Tooling en camino. Angular está añadiendo soporte para Signal Forms en su MCP oficial. Los agentes de IA podrán entender y escribir Signal Forms nativamente. Esto es crítico: los Signal Forms son tan nuevos que no existen en los datos de entrenamiento de los modelos. Sin MCP, los agentes no saben que existen. Con MCP, tendrán acceso a la documentación oficial en tiempo real y podrán generar código correcto desde el día uno.

Mirando hacia delante

Tres días después volví a abrir el código viejo de reservas. Suscripciones a valueChanges por todas partes. Llamadas a patchValue para sincronizar. ChangeDetectorRef inyectado en cada componente con formulario. 80 líneas de código que solo existen para mantener dos sistemas hablando entre sí. Todo ese código se siente "extraño" ahora.

No está mal. Funcionaba. Era lo correcto cuando se escribió. Pero Angular evolucionó. Ese código se quedó respondiendo a un framework que ya no existe. Los Signal Forms no son una mejora incremental. Son una respuesta a una arquitectura completamente diferente.

Angular es Signals. Zoneless por defecto. Change detection quirúrgica (Fine-grained reactivity). Los formularios tenían que cambiar. No porque los viejos fueran malos. Porque el mundo alrededor cambió. Quedan bugs por encontrar. Casos donde los Signal Forms no funcionan como espero. Patrones que aún no descubrí.

Pero la dirección es clara.

Los formularios que construirás en los próximos años serán diferentes. Una fuente de verdad. Validación y estado declarativo. Sin sincronización. Sin pegamento entre paradigmas. El modelo mental cambia. En vez de "mantener el form y el modelo sincronizados", piensas "el form es una vista del modelo". En vez de "cómo convierto este Observable a Signal", usas Signals de principio a fin. Más simple. Más cercano a cómo pensamos sobre estado. Adóptalo ahora o espera a que sea estable. Aprende el paradigma. Los Reactive Forms funcionarán años. Pero cada línea nueva es una línea más que migrar después.

Angular marcó el camino.

"El formulario es el modelo."

Ahora lo entiendo. Esto no es una novedad de Angular más. Era inevitable.

Con Reactive Forms sincronizaba dos mundos. Los Signal Forms solo tienen uno.

¿Qué parte de tu código deja de existir si el modelo manda?


¿Quieres probar Signal Forms? Tutorial oficial · API de referencia