🇺🇸 EN
Angular
·8 min read·↗·🇺🇸 EN

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

Portada principal del artículo: 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:

  1. Tipo de vuelo: Alternar entre Ida y vuelta (Round Trip) y Solo ida (One Way).
  2. Campos condicionales: Si se selecciona Solo ida, ocultar y deshabilitar el selector de fecha de regreso.
  3. Validaciones obligatorias: Origen, destino, fecha de salida y cantidad de pasajeros son requeridos.
  4. 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:

  1. Rendimiento Zoneless: Las actualizaciones de grano fino hacen que solo se vuelva a renderizar el nodo del DOM vinculado cuando cambia un campo.
  2. 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.
  3. 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 🧪

EnfoqueComplejidad del test¿Requiere TestBed / DOM?Ejemplo de código
Template-DrivenAlta✅ Sí (Renderizado DOM con Fixture)fixture.nativeElement.querySelector('input')
Reactive FormsBaja❌ No (TypeScript puro)component.form.controls.from.setValue('JFK')
Signal-DrivenMí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ísticaTemplate-Driven FormsReactive FormsSignal-Driven Forms
Mecanismo de configuraciónDirectivas HTML (ngModel)Clase TypeScript (FormGroup)Primitivos (signal(), model())
Modelo de reactividadTwo-way data bindingStreams de observables (valueChanges)Signals reactivos de grano fino
Estrategia de validaciónAtributos validadores en HTMLArreglos de validadores funcionalesLógica derivada pura con computed() / Zod
Inferencia en TypeScriptVerificaciones básicas en plantillaInferencia con tipado estricto totalPrimitivos nativos de TypeScript
Listo para Zoneless AngularDepende de Change Detection tradicionalParcial🚀 100% nativo para Zoneless
Pruebas unitariasRequiere renderizado en DOMRápido en TypeScript puroInmediato en TypeScript puro
Ideal paraFormularios simples, login / contactoFormularios empresariales, wizards de varios pasosWidgets ligeros, apps modernas zoneless

Resumen 🛠️

Angular hoy nos ofrece un abanico completo para gestionar formularios:

  1. Elige Template-Driven Forms para formularios rápidos y sencillos donde prefieras una configuración basada en HTML.
  2. Elige Reactive Forms para aplicaciones empresariales complejas, formularios de varios pasos y arreglos dinámicos de controles.
  3. 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.

Parte de la serie Angular

These are my experiences learning and facing my daily challenges working with Angular.

Ver serie completa

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

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.

User avatar
User avatar
User avatar
User avatar
+13K

Únete a 13,800+ desarrolladores y lectores.

Cero spam. Cancela tu suscripción en cualquier momento.