Signal forms
Angular 20 introduce una API experimental para formularios que aprovecha completamente el ecosistema de signals, representando una evolución natural de los reactive forms tradicionales.
Esta nueva aproximación, conocida como signal forms, redefine cómo interactuamos con los datos de formularios mediante un enfoque model-first donde los signals son la fuente única de verdad.
La función form()
El corazón de la nueva API es la función form()
, que crea una instancia de formulario basada en signals. A diferencia de FormGroup
que mantiene su propio estado interno, form()
utiliza WritableSignals como contenedores de datos, proporcionando reactividad automática y integración nativa con el ecosistema de signals.
import { form } from '@angular/forms';
import { signal } from '@angular/core';
@Component({
selector: 'app-user-form',
standalone: true,
template: `
<form [formGroup]="userForm.group">
<input [formControl]="userForm.controls.name">
<input [formControl]="userForm.controls.email" type="email">
<p>Nombre: {{ userForm.controls.name.value() }}</p>
<p>Email: {{ userForm.controls.email.value() }}</p>
</form>
`
})
export class UserFormComponent {
userForm = form({
name: signal(''),
email: signal('')
});
}
Enfoque model-first
La filosofía model-first de signal forms invierte el paradigma tradicional. En lugar de crear controles que después se vinculan a datos, definimos directamente los WritableSignals que representan nuestro modelo de datos:
@Component({
selector: 'app-product-form',
standalone: true,
template: `
<div>
<input [formControl]="productForm.controls.title">
<input [formControl]="productForm.controls.price" type="number">
<textarea [formControl]="productForm.controls.description"></textarea>
</div>
@if (productForm.valid()) {
<div class="preview">
<h3>{{ productModel().title }}</h3>
<p>Precio: {{ productModel().price }}€</p>
<p>{{ productModel().description }}</p>
</div>
}
`
})
export class ProductFormComponent {
// Los signals son la fuente de verdad
private titleSignal = signal('');
private priceSignal = signal(0);
private descriptionSignal = signal('');
productForm = form({
title: this.titleSignal,
price: this.priceSignal,
description: this.descriptionSignal
});
// Computed signal derivado automáticamente
productModel = computed(() => ({
title: this.titleSignal(),
price: this.priceSignal(),
description: this.descriptionSignal()
}));
}
Field objects y estado reactivo
Cada campo en un signal form se representa mediante un Field object que proporciona acceso reactivo al estado del formulario. Estos objetos exponen propiedades como signals que se actualizan automáticamente:
@Component({
selector: 'app-registration-form',
standalone: true,
template: `
<form [formGroup]="registrationForm.group">
<input [formControl]="registrationForm.controls.username">
@if (registrationForm.controls.username.invalid()) {
<span class="error">Username es requerido</span>
}
<input [formControl]="registrationForm.controls.password" type="password">
@if (registrationForm.controls.password.touched() && registrationForm.controls.password.invalid()) {
<span class="error">Password debe tener al menos 8 caracteres</span>
}
<button [disabled]="registrationForm.invalid()">
Registrarse
</button>
</form>
`
})
export class RegistrationFormComponent {
registrationForm = form({
username: signal('', { validators: [Validators.required] }),
password: signal('', {
validators: [Validators.required, Validators.minLength(8)]
})
});
constructor() {
// Reactividad automática con effects
effect(() => {
const username = this.registrationForm.controls.username.value();
const password = this.registrationForm.controls.password.value();
if (username && password) {
console.log('Formulario completado:', { username, password });
}
});
}
}
Diferencias clave con reactive forms tradicionales
La diferencia fundamental radica en la arquitectura de datos. Mientras que FormControl
mantiene su propio estado interno accesible mediante .value
, los signal forms utilizan WritableSignals como contenedores primarios:
Reactive Forms tradicionales:
// El FormControl mantiene el estado
const nameControl = new FormControl('');
console.log(nameControl.value); // Acceso imperativo
Signal Forms:
// El signal mantiene el estado
const nameSignal = signal('');
const nameField = form({ name: nameSignal });
console.log(nameField.controls.name.value()); // Acceso reactivo
Integración con el ecosistema signals
La verdadera ventaja emerge al combinar signal forms con computed signals, effects y otros primitivos reactivos, creando flujos de datos completamente declarativos:
@Component({
selector: 'app-calculator-form',
standalone: true,
template: `
<form [formGroup]="calculatorForm.group">
<input [formControl]="calculatorForm.controls.quantity" type="number">
<input [formControl]="calculatorForm.controls.unitPrice" type="number">
</form>
<div class="results">
<p>Subtotal: {{ subtotal() }}€</p>
<p>IVA (21%): {{ taxAmount() }}€</p>
<p>Total: {{ total() }}€</p>
</div>
`
})
export class CalculatorFormComponent {
private quantitySignal = signal(1);
private unitPriceSignal = signal(0);
calculatorForm = form({
quantity: this.quantitySignal,
unitPrice: this.unitPriceSignal
});
// Computed signals derivados automáticamente
subtotal = computed(() =>
this.quantitySignal() * this.unitPriceSignal()
);
taxAmount = computed(() =>
this.subtotal() * 0.21
);
total = computed(() =>
this.subtotal() + this.taxAmount()
);
}
Estado experimental y consideraciones
Es importante recordar que signal forms se encuentra en estado experimental en Angular 20+. Esto significa que la API puede sufrir cambios significativos en futuras versiones. La documentación oficial recomienda evaluar cuidadosamente su adopción en proyectos de producción, considerando factores como:
- Estabilidad de la API: Los nombres de funciones y parámetros pueden cambiar
- Compatibilidad hacia atrás: No hay garantías de migración automática
- Ecosistema de librerías: Las librerías de terceros pueden no soportar signal forms inicialmente
Para proyectos que requieren máxima estabilidad, es recomendable mantener reactive forms tradicionales y monitorear la evolución de signal forms hacia una API estable.
Migración desde reactive forms tradicionales
La transición desde reactive forms tradicionales hacia signal forms requiere una estrategia cuidadosa que permita aprovechar las ventajas del nuevo paradigma sin comprometer la estabilidad del código existente. Angular 20+ ofrece varios patrones de migración que facilitan esta transición de manera gradual y controlada.
Estrategia de migración gradual
La aproximación más segura consiste en migrar componente por componente, comenzando por formularios simples y avanzando hacia los más complejos. Esta estrategia permite evaluar el impacto y ajustar el enfoque según la experiencia adquirida:
// ANTES: Reactive form tradicional
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="contactForm">
<input formControlName="name">
<input formControlName="email" type="email">
<textarea formControlName="message"></textarea>
<button [disabled]="contactForm.invalid">Enviar</button>
</form>
`
})
export class ContactFormComponent {
contactForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
message: ['', Validators.required]
});
constructor(private fb: FormBuilder) {}
onSubmit() {
if (this.contactForm.valid) {
console.log(this.contactForm.value);
}
}
}
// DESPUÉS: Signal form migrado
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [ReactiveFormsModule], // Todavía necesario para compatibilidad
template: `
<form [formGroup]="contactForm.group">
<input [formControl]="contactForm.controls.name">
<input [formControl]="contactForm.controls.email" type="email">
<textarea [formControl]="contactForm.controls.message"></textarea>
<button [disabled]="contactForm.invalid()">Enviar</button>
</form>
`
})
export class ContactFormComponent {
contactForm = form({
name: signal('', { validators: [Validators.required] }),
email: signal('', {
validators: [Validators.required, Validators.email]
}),
message: signal('', { validators: [Validators.required] })
});
onSubmit() {
if (this.contactForm.valid()) {
// Acceso directo a los signals
const formData = {
name: this.contactForm.controls.name.value(),
email: this.contactForm.controls.email.value(),
message: this.contactForm.controls.message.value()
};
console.log(formData);
}
}
}
Interoperabilidad durante la migración
Durante el proceso de migración, es común tener formularios híbridos donde algunos componentes utilizan reactive forms y otros signal forms. Angular permite esta coexistencia mediante patrones de interoperabilidad:
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
<!-- Formulario legacy que aún no se ha migrado -->
<app-address-form
[addressForm]="legacyAddressForm"
(addressChange)="onAddressChange($event)">
</app-address-form>
<!-- Nuevo formulario con signals -->
<form [formGroup]="personalInfoForm.group">
<input [formControl]="personalInfoForm.controls.firstName">
<input [formControl]="personalInfoForm.controls.lastName">
</form>
<div class="summary">
<h3>{{ fullName() }}</h3>
<p>{{ currentAddress() }}</p>
</div>
`
})
export class UserProfileComponent {
// Formulario legacy (pendiente de migración)
legacyAddressForm = this.fb.group({
street: [''],
city: [''],
postalCode: ['']
});
// Nuevo formulario con signals
personalInfoForm = form({
firstName: signal(''),
lastName: signal('')
});
// Signal para datos del formulario legacy
private addressSignal = signal({
street: '',
city: '',
postalCode: ''
});
// Computed signals que combinan ambos enfoques
fullName = computed(() => {
const firstName = this.personalInfoForm.controls.firstName.value();
const lastName = this.personalInfoForm.controls.lastName.value();
return `${firstName} ${lastName}`.trim();
});
currentAddress = computed(() => {
const addr = this.addressSignal();
return `${addr.street}, ${addr.city} ${addr.postalCode}`;
});
constructor(private fb: FormBuilder) {}
onAddressChange(address: any) {
// Sincronización desde formulario legacy hacia signal
this.addressSignal.set(address);
}
}
Patrones de conversión para formularios complejos
Los formularios con FormArrays y validadores personalizados requieren patrones específicos de conversión que preserven la funcionalidad original:
// ANTES: FormArray tradicional
@Component({
selector: 'app-skills-form',
template: `
<form [formGroup]="skillsForm">
<div formArrayName="skills">
@for (skill of skillsArray.controls; track $index) {
<div [formGroupName]="$index">
<input formControlName="name" placeholder="Skill">
<input formControlName="level" type="number" min="1" max="10">
<button (click)="removeSkill($index)">Eliminar</button>
</div>
}
</div>
<button (click)="addSkill()">Agregar Skill</button>
</form>
`
})
export class SkillsFormComponent {
skillsForm = this.fb.group({
skills: this.fb.array([])
});
get skillsArray() {
return this.skillsForm.get('skills') as FormArray;
}
addSkill() {
const skillGroup = this.fb.group({
name: ['', Validators.required],
level: [1, [Validators.min(1), Validators.max(10)]]
});
this.skillsArray.push(skillGroup);
}
removeSkill(index: number) {
this.skillsArray.removeAt(index);
}
constructor(private fb: FormBuilder) {}
}
// DESPUÉS: Signal form con arrays dinámicos
@Component({
selector: 'app-skills-form',
template: `
<div>
@for (skill of skills(); track skill.id) {
<div class="skill-item">
<input
[value]="skill.name()"
(input)="updateSkillName(skill.id, $event)">
<input
[value]="skill.level()"
(input)="updateSkillLevel(skill.id, $event)"
type="number" min="1" max="10">
<button (click)="removeSkill(skill.id)">Eliminar</button>
</div>
}
<button (click)="addSkill()">Agregar Skill</button>
</div>
<div class="validation-summary">
@if (hasValidationErrors()) {
<p class="error">Corrige los errores antes de continuar</p>
}
</div>
`
})
export class SkillsFormComponent {
private nextId = 1;
skills = signal<Array<{
id: number;
name: WritableSignal<string>;
level: WritableSignal<number>;
valid: Signal<boolean>;
}>>([]);
// Computed signal para validación global
hasValidationErrors = computed(() => {
return this.skills().some(skill => !skill.valid());
});
addSkill() {
const nameSignal = signal('');
const levelSignal = signal(1);
const skillValid = computed(() => {
const name = nameSignal();
const level = levelSignal();
return name.length > 0 && level >= 1 && level <= 10;
});
const newSkill = {
id: this.nextId++,
name: nameSignal,
level: levelSignal,
valid: skillValid
};
this.skills.update(current => [...current, newSkill]);
}
removeSkill(id: number) {
this.skills.update(current =>
current.filter(skill => skill.id !== id)
);
}
updateSkillName(id: number, event: Event) {
const target = event.target as HTMLInputElement;
const skill = this.skills().find(s => s.id === id);
if (skill) {
skill.name.set(target.value);
}
}
updateSkillLevel(id: number, event: Event) {
const target = event.target as HTMLInputElement;
const skill = this.skills().find(s => s.id === id);
if (skill) {
skill.level.set(parseInt(target.value, 10));
}
}
}
Criterios para decidir cuándo migrar
La decisión de migrar un formulario específico debe basarse en criterios técnicos y de negocio bien definidos:
Candidatos ideales para migración temprana:
- Formularios nuevos que se desarrollen desde cero
- Formularios simples con pocos campos y validaciones básicas
- Componentes con alta reactividad que se beneficien de computed signals
- Formularios que interactúan intensamente con otros signals del componente
Formularios que deberían permanecer como reactive forms:
- Formularios críticos en producción con validaciones complejas
- Componentes legacy con poca frecuencia de cambios
- Formularios que dependen de librerías de terceros no compatibles con signals
- Equipos con recursos limitados para testing y validación
Herramientas de apoyo para la migración
El proceso de migración se puede automatizar parcialmente mediante herramientas y scripts personalizados que identifiquen patrones comunes:
// Utility helper para facilitar la migración
export class FormMigrationHelper {
static convertFormGroupToSignalForm(formGroup: FormGroup): any {
const signalForm: Record<string, WritableSignal<any>> = {};
Object.keys(formGroup.controls).forEach(key => {
const control = formGroup.get(key);
if (control) {
signalForm[key] = signal(control.value);
}
});
return form(signalForm);
}
static extractValidators(control: AbstractControl): ValidatorFn[] {
// Lógica para extraer validadores existentes
return [];
}
static createMigrationReport(component: any): MigrationReport {
// Análisis automático del componente
return {
complexity: 'medium',
dependencies: [],
estimatedEffort: '2-3 días',
risks: ['Validadores personalizados', 'Formularios anidados']
};
}
}
interface MigrationReport {
complexity: 'low' | 'medium' | 'high';
dependencies: string[];
estimatedEffort: string;
risks: string[];
}
La migración hacia signal forms representa una evolución natural del desarrollo de formularios en Angular, pero debe abordarse con planificación cuidadosa y evaluación continua del impacto en el código existente.

Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Angular es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Angular
Explora más contenido relacionado con Angular y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
- Comprender la nueva API experimental de signal forms y su función principal
form()
. - Aprender el enfoque model-first basado en signals para gestionar formularios.
- Diferenciar signal forms de los reactive forms tradicionales en Angular.
- Conocer patrones y estrategias para migrar formularios existentes a signal forms.
- Explorar la integración de signal forms con computed signals, effects y otros primitivos reactivos.