Formularios en Angular: Cómo elegir entre Template-Driven, Reactive y Signal-Driven Forms

Al construir aplicaciones web en Angular, elegir cómo manejar los datos que ingresa el usuario es una de las decisiones de arquitectura más importantes. Hoy en día, tenemos tres paradigmas diferentes para elegir: Template-Driven Forms, Reactive Forms y Signal-Driven Forms.
Para quienes vienen de otros frameworks o están creando prototipos sencillos, los Template-Driven forms resultan naturales y familiares. En proyectos empresariales con formularios complejos, solemos preferir Reactive Forms por su tipado estricto y previsibilidad. Además, con los Signals del Angular moderno, los Signal-Driven forms ofrecen una alternativa ligera y sin código repetitivo.
⚡ ¿Usas Angular moderno (v17+)? Ve directamente a Enfoque 3: Signal-Driven Forms modernos para ver cómo gestionar el estado del formulario con reactividad de grano fino y sin la sobrecarga de FormModule.
En esta guía, vamos a construir exactamente el mismo formulario de reserva de vuelos con los tres enfoques para comparar su ergonomía, el manejo de validaciones y el rendimiento.
Primero, definamos los requisitos de nuestro proyecto.
1. El escenario: Búsqueda de reserva de vuelos ✈️
Nuestro objetivo es construir una interfaz para buscar vuelos con las siguientes reglas de negocio:
- Tipo de vuelo: Alternar entre Ida y vuelta (Round Trip) y Solo ida (One Way).
- Campos condicionales: Si se selecciona Solo ida, ocultar y deshabilitar el selector de fecha de regreso.
- Validaciones obligatorias: Origen, destino, fecha de salida y cantidad de pasajeros son requeridos.
- Botón de envío: Deshabilitado automáticamente hasta que todos los campos requeridos cumplan con su validación.
Ahora veamos el Enfoque 1: Template-Driven Forms.
2. Enfoque 1: Template-Driven Forms (ngModel) 📄
En los Template-Driven forms, la plantilla HTML es la fuente de la verdad. Angular crea y gestiona el modelo del formulario por debajo mediante directivas.
Definición del componente
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
@Component({
selector: 'app-flights-template',
standalone: true,
imports: [FormsModule],
templateUrl: './flights-template.component.html'
})
export class FlightsTemplateComponent {
flightType = 'roundtrip';
from = '';
to = '';
depart = '';
returnDate = '';
passengers = 1;
passengerOptions = [1, 2, 3, 4, 5];
onSubmit(form: NgForm) {
if (form.valid) {
console.log('Flight booking query:', form.value);
}
}
}Plantilla HTML con ngModel
<form #flightForm="ngForm" (ngSubmit)="onSubmit(flightForm)" novalidate>
<!-- Radios para el tipo de vuelo -->
<fieldset class="form-group">
<legend>Flight Type:</legend>
<label>
<input type="radio" name="flightType" value="roundtrip" [(ngModel)]="flightType" required />
Round trip
</label>
<label>
<input type="radio" name="flightType" value="oneway" [(ngModel)]="flightType" required />
One way
</label>
</fieldset>
<!-- Aeropuerto de origen -->
<div class="form-group">
<label for="fromAirport">From:</label>
<select id="fromAirport" name="from" [(ngModel)]="from" required>
<option value="" disabled>Select origin airport</option>
<option value="JFK">New York (JFK)</option>
<option value="LAX">Los Angeles (LAX)</option>
</select>
</div>
<!-- Fecha de regreso condicional con Control Flow moderno -->
@if (flightType === 'roundtrip') {
<div class="form-group">
<label for="returnDate">Return Date:</label>
<input type="date" id="returnDate" name="returnDate" [(ngModel)]="returnDate" required />
</div>
}
<!-- Botón de envío -->
<button type="submit" [disabled]="!flightForm.valid" class="btn-primary">
Search Flights
</button>
</form>Ventajas y desventajas de Template-Driven Forms:
- ✅ Ventajas: Rápido de escribir, código TypeScript mínimo, intuitivo para quienes empiezan.
- ❌ Desventajas: Difícil de probar con pruebas unitarias sin renderizar el DOM, y la lógica de validación queda repartida en atributos HTML.
Ahora construyamos exactamente el mismo formulario usando Reactive Forms.
3. Enfoque 2: Reactive Forms (FormGroup y FormBuilder) ⚙️
En los Reactive Forms, la clase de TypeScript del componente es la fuente directa de la verdad. Declaramos el modelo del formulario de forma programática, lo que permite tipado estricto, inspección síncrona y pruebas unitarias sencillas.
Definición del componente con NonNullableFormBuilder
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-flights-reactive',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './flights-reactive.component.html'
})
export class FlightsReactiveComponent {
private readonly fb = inject(FormBuilder);
readonly flightForm = this.fb.group({
flightType: this.fb.control('roundtrip', { nonNullable: true, validators: [Validators.required] }),
from: this.fb.control('', { nonNullable: true, validators: [Validators.required] }),
to: this.fb.control('', { nonNullable: true, validators: [Validators.required] }),
depart: this.fb.control('', { nonNullable: true, validators: [Validators.required] }),
returnDate: this.fb.control(''),
passengers: this.fb.control(1, { nonNullable: true, validators: [Validators.required] }),
});
onSubmit() {
if (this.flightForm.valid) {
console.log('Reactive form submission:', this.flightForm.getRawValue());
}
}
}Plantilla HTML con formControlName
<form [formGroup]="flightForm" (ngSubmit)="onSubmit()" novalidate>
<fieldset class="form-group">
<legend>Flight Type:</legend>
<label>
<input type="radio" formControlName="flightType" value="roundtrip" />
Round trip
</label>
<label>
<input type="radio" formControlName="flightType" value="oneway" />
One way
</label>
</fieldset>
<div class="form-group">
<label for="rf-from">From:</label>
<select id="rf-from" formControlName="from">
<option value="" disabled>Select origin airport</option>
<option value="JFK">New York (JFK)</option>
<option value="LAX">Los Angeles (LAX)</option>
</select>
</div>
@if (flightForm.controls.flightType.value === 'roundtrip') {
<div class="form-group">
<label for="rf-return">Return Date:</label>
<input type="date" id="rf-return" formControlName="returnDate" />
</div>
}
<button type="submit" [disabled]="flightForm.invalid" class="btn-primary">
Search Flights
</button>
</form>Ahora pasemos al tercer paradigma que viene tomando fuerza: Signal-Driven Forms.
4. Enfoque 3: Signal-Driven Forms modernos (signal(), computed() y model()) ⚡
Con los Signals nativos de Angular, podemos manejar el estado del formulario sin importar @angular/forms ni depender de observables de RxJS. Cada campo es un signal() o model() independiente, y la validación se calcula de forma síncrona mediante signals computed() puros.
Definición del componente usando Signals
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-flights-signals',
standalone: true,
templateUrl: './flights-signals.component.html'
})
export class FlightsSignalsComponent {
// 1. Signals para el estado primitivo del formulario
readonly flightType = signal<'roundtrip' | 'oneway'>('roundtrip');
readonly from = signal('');
readonly to = signal('');
readonly depart = signal('');
readonly returnDate = signal('');
readonly passengers = signal(1);
// 2. Validaciones calculadas a nivel de campo
readonly isFromValid = computed(() => this.from().trim().length > 0);
readonly isToValid = computed(() => this.to().trim().length > 0 && this.to() !== this.from());
readonly isDepartValid = computed(() => this.depart().trim().length > 0);
readonly isReturnValid = computed(() => {
if (this.flightType() === 'oneway') return true;
return this.returnDate().trim().length > 0 && this.returnDate() >= this.depart();
});
// 3. Validez calculada a nivel de formulario (cero código repetitivo)
readonly isFormValid = computed(() =>
this.isFromValid() &&
this.isToValid() &&
this.isDepartValid() &&
this.isReturnValid() &&
this.passengers() >= 1
);
onSubmit(event: Event) {
event.preventDefault();
if (this.isFormValid()) {
const payload = {
flightType: this.flightType(),
from: this.from(),
to: this.to(),
depart: this.depart(),
returnDate: this.flightType() === 'roundtrip' ? this.returnDate() : null,
passengers: this.passengers(),
};
console.log('Signal form payload:', payload);
}
}
}Plantilla HTML con bindings nativos
<form (submit)="onSubmit($event)" novalidate>
<fieldset class="form-group">
<legend>Flight Type:</legend>
<label>
<input
type="radio"
name="flightType"
value="roundtrip"
[checked]="flightType() === 'roundtrip'"
(change)="flightType.set('roundtrip')"
/>
Round trip
</label>
<label>
<input
type="radio"
name="flightType"
value="oneway"
[checked]="flightType() === 'oneway'"
(change)="flightType.set('oneway')"
/>
One way
</label>
</fieldset>
<div class="form-group">
<label for="sig-from">From:</label>
<select
id="sig-from"
[value]="from()"
(change)="from.set($any($event.target).value)"
>
<option value="" disabled>Select origin airport</option>
<option value="JFK">New York (JFK)</option>
<option value="LAX">Los Angeles (LAX)</option>
</select>
</div>
@if (flightType() === 'roundtrip') {
<div class="form-group">
<label for="sig-return">Return Date:</label>
<input
type="date"
id="sig-return"
[value]="returnDate()"
(input)="returnDate.set($any($event.target).value)"
/>
@if (!isReturnValid() && returnDate()) {
<p class="text-red-500 text-xs">Return date must be after departure date.</p>
}
</div>
}
<button type="submit" [disabled]="!isFormValid()" class="btn-primary">
Search Flights
</button>
</form>Por qué los Signal-Driven Forms son tan convenientes:
- Rendimiento Zoneless: Las actualizaciones de grano fino hacen que solo se vuelva a renderizar el nodo del DOM vinculado cuando cambia un campo.
- Síncrono y componible: Los cálculos derivados, como las comparaciones entre campos, se ejecutan al instante sin necesidad de canalizaciones complejas de RxJS con
combineLatest. - Cero sobrecarga de dependencias: No necesitamos módulos pesados de formularios para componentes o widgets sencillos.
Una vez que tenemos claros los tres enfoques en código, veamos cómo se comportan al momento de escribir pruebas unitarias.
5. Testing: Comparación de pruebas entre los 3 enfoques 🧪
| Enfoque | Complejidad del test | ¿Requiere TestBed / DOM? | Ejemplo de código |
|---|---|---|---|
| Template-Driven | Alta | ✅ Sí (Renderizado DOM con Fixture) | fixture.nativeElement.querySelector('input') |
| Reactive Forms | Baja | ❌ No (TypeScript puro) | component.form.controls.from.setValue('JFK') |
| Signal-Driven | Mínima | ❌ No (TypeScript puro) | component.from.set('JFK'); expect(component.isFormValid()).toBe(...) |
Para tener una vista panorámica de cada opción, reuní las principales características técnicas en esta tabla comparativa.
Tabla comparativa: Los 3 paradigmas de formularios en Angular 📊
| Característica | Template-Driven Forms | Reactive Forms | Signal-Driven Forms |
|---|---|---|---|
| Mecanismo de configuración | Directivas HTML (ngModel) | Clase TypeScript (FormGroup) | Primitivos (signal(), model()) |
| Modelo de reactividad | Two-way data binding | Streams de observables (valueChanges) | Signals reactivos de grano fino |
| Estrategia de validación | Atributos validadores en HTML | Arreglos de validadores funcionales | Lógica derivada pura con computed() / Zod |
| Inferencia en TypeScript | Verificaciones básicas en plantilla | Inferencia con tipado estricto total | Primitivos nativos de TypeScript |
| Listo para Zoneless Angular | Depende de Change Detection tradicional | Parcial | 🚀 100% nativo para Zoneless |
| Pruebas unitarias | Requiere renderizado en DOM | Rápido en TypeScript puro | Inmediato en TypeScript puro |
| Ideal para | Formularios simples, login / contacto | Formularios empresariales, wizards de varios pasos | Widgets ligeros, apps modernas zoneless |
Resumen 🛠️
Angular hoy nos ofrece un abanico completo para gestionar formularios:
- Elige Template-Driven Forms para formularios rápidos y sencillos donde prefieras una configuración basada en HTML.
- Elige Reactive Forms para aplicaciones empresariales complejas, formularios de varios pasos y arreglos dinámicos de controles.
- Elige Signal-Driven Forms para componentes ligeros, preparados para Zoneless y con validación calculada inmediata.
Si quieres leer más guías sobre Angular moderno, te invito a revisar mis artículos sobre Cómo crear formularios dinámicos en Angular y Cómo simplificar la comunicación entre componentes con model() y Signals.
These are my experiences learning and facing my daily challenges working with Angular.
Preguntas Frecuentes
¿Cuál es la diferencia entre los formularios Template-Driven, Reactive y Signal-Driven en Angular?
Los formularios Template-Driven gestionan el estado de forma implícita mediante directivas en la plantilla (ngModel). Los Reactive Forms gestionan el estado explícitamente en clases de TypeScript usando FormGroup, FormControl y NonNullableFormBuilder. Los Signal-Driven forms gestionan el estado con Signals primitivos (signal(), computed(), model()), lo que ofrece reactividad de grano fino y rendimiento zoneless sin abstracciones pesadas de FormModule.
¿Cuándo debería usar Signal-Driven forms en lugar de Reactive Forms en Angular?
Usa Signal-Driven forms al construir formularios ligeros, widgets independientes o aplicaciones en Zoneless Angular donde busques cero sobrecarga de suscripciones RxJS y una validación síncrona inmediata con signals computed().
¿Los Reactive Forms de Angular tienen tipado estricto en el Angular moderno?
Sí. A partir de Angular 14 y con mejoras en v17+, los Reactive Forms tienen tipado estricto por defecto. También puedes usar FormBuilder.nonNullable (o NonNullableFormBuilder) para asegurar que los valores del formulario nunca vuelvan a undefined o null.
¿Cómo se validan los Signal-Driven forms en Angular moderno?
La validación en Signal-Driven forms se maneja de forma limpia mediante signals computed(). Puedes escribir reglas declarativas sencillas (por ejemplo, const isValid = computed(() => email().includes('@'))) o integrar esquemas de validación como Zod o Valibot directamente dentro de un signal computed().
Artículos Relacionados
Cómo crear formularios dinámicos en Angular: Guía paso a paso
Aprende a construir formularios dinámicos tipados y basados en datos en Angular moderno usando Formularios Reactivos, Control Flow (@switch, @for) y validaciones.
Cómo usar ng-template, ng-container y ng-content en Angular (Guía completa)
Aprende las diferencias exactas entre ng-template, ng-container y ng-content en Angular, cómo pasar contexto con ngTemplateOutlet, proyección con múltiples slots y cómo evolucionan con el control flow moderno de @if, @for y @defer.
Cómo usar parámetros de ruta y query params en Angular (con Signals e Inputs)
Aprende a pasar y leer parámetros de ruta, query params y parámetros de matriz en Angular usando Signals, withComponentInputBinding y ActivatedRoute.
Compartir este artículo
Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.
Software real. Lecciones reales.
Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.
Únete a 13,800+ desarrolladores y lectores.
Cero spam. Cancela tu suscripción en cualquier momento.