Inicio / Angular / Angular Completo: De Cero a Experto / Formularios en Angular

Formularios en Angular

Aprende Template-driven Forms y Reactive Forms, validaciones personalizadas, FormArray para listas dinámicas y gestión de estado del formulario.


Formularios en Angular

Angular ofrece dos enfoques para trabajar con formularios: Template-driven y Reactive Forms. Ambos tienen sus casos de uso. Los Reactive Forms son más potentes y testeables para formularios complejos.


Template-driven Forms

Son más simples y usan directivas en el HTML. Ideales para formularios pequeños.

import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-contacto',
  standalone: true,
  imports: [FormsModule],
  template: `
    <form #contactoForm="ngForm" (ngSubmit)="onSubmit(contactoForm)">

      <!-- Campo de texto -->
      <div>
        <label>Nombre *</label>
        <input
          name="nombre"
          [(ngModel)]="datos.nombre"
          required
          minlength="3"
          #nombreCtrl="ngModel">

        <!-- Mensajes de error -->
        @if (nombreCtrl.invalid && nombreCtrl.touched) {
          @if (nombreCtrl.errors?.['required']) {
            <span class="error">El nombre es requerido</span>
          }
          @if (nombreCtrl.errors?.['minlength']) {
            <span class="error">Mínimo 3 caracteres</span>
          }
        }
      </div>

      <!-- Email -->
      <div>
        <label>Email *</label>
        <input
          name="email"
          type="email"
          [(ngModel)]="datos.email"
          required
          email
          #emailCtrl="ngModel">

        @if (emailCtrl.invalid && emailCtrl.touched) {
          <span class="error">Email inválido</span>
        }
      </div>

      <!-- Select -->
      <div>
        <label>País</label>
        <select name="pais" [(ngModel)]="datos.pais">
          <option value="">Selecciona un país</option>
          <option value="mx">México</option>
          <option value="co">Colombia</option>
          <option value="ar">Argentina</option>
        </select>
      </div>

      <!-- Textarea -->
      <div>
        <label>Mensaje *</label>
        <textarea
          name="mensaje"
          [(ngModel)]="datos.mensaje"
          required
          minlength="10"
          rows="4">
        </textarea>
      </div>

      <!-- Checkbox -->
      <div>
        <input type="checkbox" name="aceptaTerminos" [(ngModel)]="datos.aceptaTerminos" required>
        <label>Acepto los términos y condiciones</label>
      </div>

      <!-- Botón (deshabilitado si el form es inválido) -->
      <button type="submit" [disabled]="contactoForm.invalid">Enviar</button>

      <!-- Estado del formulario para debug -->
      <pre>{{ contactoForm.value | json }}</pre>
    </form>
  `
})
export class ContactoComponent {
  datos = {
    nombre: '',
    email: '',
    pais: '',
    mensaje: '',
    aceptaTerminos: false
  };

  onSubmit(form: NgForm): void {
    if (form.valid) {
      console.log('Datos:', form.value);
      // Enviar al servicio
      form.resetForm();  // Limpiar el formulario
    }
  }
}

Reactive Forms

Los Reactive Forms se crean programáticamente en TypeScript, dando mayor control, validaciones personalizadas y facilidad para testear.

import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, AbstractControl } from '@angular/forms';

@Component({
  selector: 'app-registro',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">

      <!-- Nombre -->
      <div>
        <label>Nombre *</label>
        <input formControlName="nombre">
        @if (f['nombre'].invalid && f['nombre'].touched) {
          @if (f['nombre'].errors?.['required']) {
            <span class="error">Requerido</span>
          }
          @if (f['nombre'].errors?.['minlength']) {
            <span class="error">Mínimo 3 caracteres</span>
          }
        }
      </div>

      <!-- Email -->
      <div>
        <label>Email *</label>
        <input formControlName="email" type="email">
        @if (f['email'].invalid && f['email'].touched) {
          <span class="error">Email inválido</span>
        }
      </div>

      <!-- Contraseña con grupo -->
      <div formGroupName="contrasenas">
        <div>
          <label>Contraseña *</label>
          <input formControlName="password" type="password">
          @if (g['password'].errors?.['pattern'] && g['password'].touched) {
            <span class="error">Mínimo 8 chars, 1 mayúscula, 1 número</span>
          }
        </div>

        <div>
          <label>Confirmar contraseña *</label>
          <input formControlName="confirmPassword" type="password">
          @if (form.get('contrasenas')?.errors?.['noCoinciden'] && g['confirmPassword'].touched) {
            <span class="error">Las contraseñas no coinciden</span>
          }
        </div>
      </div>

      <!-- Radio buttons -->
      <div>
        <label>Rol</label>
        <label><input type="radio" formControlName="rol" value="viewer"> Viewer</label>
        <label><input type="radio" formControlName="rol" value="editor"> Editor</label>
        <label><input type="radio" formControlName="rol" value="admin"> Admin</label>
      </div>

      <!-- Estado del form -->
      <p>Formulario: {{ form.status }}</p>

      <button type="submit" [disabled]="form.invalid || enviando">
        {{ enviando ? 'Registrando...' : 'Registrarse' }}
      </button>
    </form>
  `
})
export class RegistroComponent implements OnInit {
  private fb = inject(FormBuilder);

  form!: FormGroup;
  enviando = false;

  // Getter para acceder fácilmente a los controles
  get f() { return this.form.controls; }
  get g() { return (this.form.get('contrasenas') as FormGroup).controls; }

  ngOnInit(): void {
    this.form = this.fb.group({
      nombre: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(50)]],
      email: ['', [Validators.required, Validators.email]],
      contrasenas: this.fb.group({
        password: ['', [
          Validators.required,
          Validators.pattern(/^(?=.*[A-Z])(?=.*\d).{8,}$/)
        ]],
        confirmPassword: ['', Validators.required]
      }, { validators: this.validarContrasenas }),
      rol: ['viewer', Validators.required],
      activo: [true]
    });
  }

  // Validador a nivel de grupo
  private validarContrasenas(grupo: AbstractControl) {
    const pass = grupo.get('password')?.value;
    const confirm = grupo.get('confirmPassword')?.value;

    return pass === confirm ? null : { noCoinciden: true };
  }

  onSubmit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();   // Mostrar errores de todos los campos
      return;
    }

    this.enviando = true;
    const datos = this.form.value;
    console.log('Registrando:', datos);

    // Simular llamada al servidor
    setTimeout(() => {
      this.enviando = false;
      this.form.reset({ rol: 'viewer', activo: true });
    }, 2000);
  }
}

FormArray — Listas dinámicas

import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, FormArray, Validators, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-experiencia',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <h2>Experiencia Laboral</h2>

      <div formArrayName="experiencias">
        @for (exp of experiencias.controls; let i = $index; track i) {
          <div [formGroupName]="i" class="experiencia-item">
            <h4>Experiencia {{ i + 1 }}</h4>
            <input formControlName="empresa" placeholder="Empresa">
            <input formControlName="cargo" placeholder="Cargo">
            <input formControlName="desde" type="date">
            <input formControlName="hasta" type="date">
            <button type="button" (click)="eliminarExperiencia(i)">🗑️ Eliminar</button>
          </div>
        }
      </div>

      <button type="button" (click)="agregarExperiencia()">+ Agregar experiencia</button>
      <button type="submit" [disabled]="form.invalid">Guardar</button>
    </form>
  `
})
export class ExperienciaComponent implements OnInit {
  private fb = inject(FormBuilder);
  form = this.fb.group({ experiencias: this.fb.array([]) });

  get experiencias(): FormArray {
    return this.form.get('experiencias') as FormArray;
  }

  ngOnInit(): void {
    this.agregarExperiencia();  // Empezar con un campo
  }

  agregarExperiencia(): void {
    this.experiencias.push(this.fb.group({
      empresa: ['', Validators.required],
      cargo: ['', Validators.required],
      desde: ['', Validators.required],
      hasta: ['']
    }));
  }

  eliminarExperiencia(indice: number): void {
    this.experiencias.removeAt(indice);
  }

  onSubmit(): void {
    console.log(this.form.value);
  }
}

Validadores personalizados

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Validador de función pura
export function sinEspacios(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    if (!control.value) return null;
    const tieneEspacios = /\s/.test(control.value);
    return tieneEspacios ? { sinEspacios: true } : null;
  };
}

// Validador que recibe parámetros
export function mayorDe(edad: number): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const valor = Number(control.value);
    return valor >= edad ? null : { mayorDe: { requerido: edad, actual: valor } };
  };
}

// Validador asíncrono (por ejemplo, verificar si el email ya existe)
export function emailDisponible(usuariosService: UsuariosService): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    return timer(500).pipe(   // debounce de 500ms
      switchMap(() => usuariosService.verificarEmail(control.value)),
      map(disponible => disponible ? null : { emailOcupado: true })
    );
  };
}

// Uso en el formulario
this.form = this.fb.group({
  username: ['', [Validators.required, sinEspacios()]],
  edad: ['', [Validators.required, Validators.min(0), mayorDe(18)]],
  email: ['', {
    validators: [Validators.required, Validators.email],
    asyncValidators: [emailDisponible(this.usuariosService)],
    updateOn: 'blur'   // Validar al perder el foco (no al escribir)
  }]
});

Escuchar cambios en el formulario

ngOnInit(): void {
  this.form = this.fb.group({
    categoria: [''],
    subcategoria: ['']
  });

  // Escuchar cambios en un control específico
  this.form.get('categoria')!.valueChanges.subscribe(categoria => {
    console.log('Categoría cambiada:', categoria);
    // Limpiar y actualizar subcategorías
    this.form.get('subcategoria')!.setValue('');
    this.cargarSubcategorias(categoria);
  });

  // Escuchar cambios en todo el formulario
  this.form.valueChanges.subscribe(valores => {
    console.log('Formulario actualizado:', valores);
    this.guardadoAutomatico(valores);
  });

  // Escuchar cambios de estado
  this.form.statusChanges.subscribe(estado => {
    console.log('Estado del formulario:', estado); // 'VALID', 'INVALID', 'PENDING'
  });
}

Comparación: Template-driven vs Reactive

Aspecto Template-driven Reactive
Configuración En el HTML En TypeScript
Validación Atributos HTML Funciones TypeScript
Formularios dinámicos Difícil Fácil con FormArray
Testing Más complejo Más simple
Cambios en tiempo real valueChanges limitado valueChanges Observable completo
Ideal para Formularios simples Formularios complejos

Gestión de estado del formulario

// Métodos útiles del FormGroup y FormControl

// Resetear
this.form.reset();
this.form.reset({ nombre: '', rol: 'viewer' });  // con valores por defecto

// Patch values (actualiza solo los campos indicados)
this.form.patchValue({ nombre: 'Ana', email: 'ana@test.com' });

// Set values (todos los campos)
this.form.setValue({ nombre: 'Ana', email: 'ana@test.com', rol: 'editor' });

// Deshabilitar/habilitar
this.form.get('email')!.disable();
this.form.get('email')!.enable();

// Marcar como tocado (para mostrar errores)
this.form.markAllAsTouched();
this.form.get('nombre')!.markAsTouched();

// Obtener errores
const errores = this.form.get('email')!.errors;

// Estado
console.log(this.form.valid);    // boolean
console.log(this.form.dirty);    // true si el usuario modificó algo
console.log(this.form.touched);  // true si el usuario tocó algún campo
console.log(this.form.pristine); // opuesto a dirty