FormArray para listas dinámicas

Avanzado
Angular
Angular
Actualizado: 24/09/2025

FormArray y controles dinámicos

Los FormArray representan una estructura de datos fundamental en Angular para manejar colecciones dinámicas de controles de formulario. A diferencia de FormGroup que agrupa controles con claves conocidas, FormArray almacena controles en formato de array indexado, permitiendo agregar, eliminar y reorganizar elementos de forma dinámica durante la ejecución.

Conceptos fundamentales de FormArray

Un FormArray es esencialmente un array de AbstractControl que puede contener FormControl, FormGroup o incluso otros FormArray anidados. Esta flexibilidad lo convierte en la herramienta ideal para formularios donde el número de campos no es fijo, como listas de teléfonos, direcciones múltiples o elementos de inventario.

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

@Component({
  selector: 'app-contact-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="contactForm">
      <div>
        <label>Nombre:</label>
        <input formControlName="name" type="text">
      </div>
      
      <div formArrayName="phones">
        <h3>Teléfonos</h3>
        @for (phone of phonesArray.controls; track $index) {
          <div>
            <input [formControlName]="$index" type="tel" placeholder="Número de teléfono">
          </div>
        }
      </div>
    </form>
  `
})
export class ContactFormComponent {
  contactForm: FormGroup;

  constructor(private fb: FormBuilder) {
    this.contactForm = this.fb.group({
      name: [''],
      phones: this.fb.array([
        this.fb.control(''),
        this.fb.control('')
      ])
    });
  }

  get phonesArray(): FormArray {
    return this.contactForm.get('phones') as FormArray;
  }
}

Diferencias clave entre FormGroup y FormArray

Mientras que FormGroup organiza controles mediante claves string conocidas en tiempo de desarrollo, FormArray utiliza índices numéricos. Esta distinción fundamental afecta tanto la forma de acceder a los controles como la estructura de datos resultante:

// FormGroup - estructura fija con claves conocidas
const userGroup = this.fb.group({
  firstName: [''],
  lastName: [''],
  email: ['']
});

// FormArray - estructura dinámica con índices
const skillsArray = this.fb.array([
  this.fb.control('JavaScript'),
  this.fb.control('TypeScript'),
  this.fb.control('Angular')
]);

El acceso a los valores también difiere significativamente. FormGroup devuelve un objeto con propiedades nombradas, mientras que FormArray produce un array de valores:

// Valor de FormGroup
{
  firstName: 'Juan',
  lastName: 'Pérez',
  email: 'juan@example.com'
}

// Valor de FormArray
['JavaScript', 'TypeScript', 'Angular']

Creación de FormArray con FormBuilder

FormBuilder ofrece múltiples formas de crear FormArray según las necesidades específicas. El método más directo utiliza fb.array() con un array inicial de controles:

@Component({
  selector: 'app-skills-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="profileForm">
      <div formArrayName="skills">
        <h3>Habilidades técnicas</h3>
        @for (skill of skillsArray.controls; track $index) {
          <div>
            <input [formControlName]="$index" type="text" placeholder="Habilidad">
            <select [formControlName]="$index + '_level'">
              <option value="basic">Básico</option>
              <option value="intermediate">Intermedio</option>
              <option value="advanced">Avanzado</option>
            </select>
          </div>
        }
      </div>
    </form>
  `
})
export class SkillsFormComponent {
  profileForm: FormGroup;

  constructor(private fb: FormBuilder) {
    this.profileForm = this.fb.group({
      skills: this.fb.array([
        this.createSkillGroup(),
        this.createSkillGroup()
      ])
    });
  }

  private createSkillGroup(): FormGroup {
    return this.fb.group({
      name: [''],
      level: ['basic']
    });
  }

  get skillsArray(): FormArray {
    return this.profileForm.get('skills') as FormArray;
  }
}

Tipos de controles en FormArray

Los FormArray pueden contener diferentes tipos de controles según la complejidad de los datos que manejen. Para datos simples como strings o números, utilizamos FormControl directamente:

// Array de strings simples
const tagsArray = this.fb.array([
  this.fb.control('Angular'),
  this.fb.control('React'),
  this.fb.control('Vue')
]);

Para datos estructurados, cada elemento del array será un FormGroup con múltiples campos:

// Array de objetos complejos
const addressesArray = this.fb.array([
  this.fb.group({
    street: [''],
    city: [''],
    zipCode: [''],
    country: [''],
    isPrimary: [false]
  })
]);

Integración con templates usando @for

La sintaxis moderna de Angular utiliza @for para iterar sobre los controles del FormArray, proporcionando acceso tanto al índice como al control individual:

@Component({
  selector: 'app-address-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="userForm">
      <div formArrayName="addresses">
        <h3>Direcciones</h3>
        @for (address of addressesArray.controls; track $index) {
          <fieldset [formGroupName]="$index">
            <legend>Dirección {{$index + 1}}</legend>
            <div>
              <label>Calle:</label>
              <input formControlName="street" type="text">
            </div>
            <div>
              <label>Ciudad:</label>
              <input formControlName="city" type="text">
            </div>
            <div>
              <label>Código postal:</label>
              <input formControlName="zipCode" type="text">
            </div>
            <div>
              <label>
                <input formControlName="isPrimary" type="checkbox">
                Dirección principal
              </label>
            </div>
          </fieldset>
        }
      </div>
    </form>
  `
})
export class AddressFormComponent {
  userForm: FormGroup;

  constructor(private fb: FormBuilder) {
    this.userForm = this.fb.group({
      name: [''],
      addresses: this.fb.array([
        this.createAddressGroup()
      ])
    });
  }

  private createAddressGroup(): FormGroup {
    return this.fb.group({
      street: [''],
      city: [''],
      zipCode: [''],
      country: ['España'],
      isPrimary: [false]
    });
  }

  get addressesArray(): FormArray {
    return this.userForm.get('addresses') as FormArray;
  }
}

Acceso y navegación de controles

FormArray proporciona varias formas de acceder a los controles individuales. El método at(index) devuelve el control en la posición especificada:

// Acceso directo por índice
const firstPhone = this.phonesArray.at(0);
const secondPhone = this.phonesArray.at(1);

// Verificación de existencia antes del acceso
const thirdPhone = this.phonesArray.at(2);
if (thirdPhone) {
  console.log('Valor del tercer teléfono:', thirdPhone.value);
}

La propiedad controls permite acceso directo al array nativo de controles, útil para operaciones que requieren todos los elementos:

// Iterar sobre todos los controles
this.skillsArray.controls.forEach((control, index) => {
  console.log(`Skill ${index}:`, control.value);
});

// Contar controles válidos
const validControls = this.skillsArray.controls.filter(control => control.valid);
console.log(`Controles válidos: ${validControls.length}`);

Manipulación de arrays: agregar, eliminar y validar

Métodos de manipulación de FormArray

Los FormArray proporcionan métodos específicos para modificar dinámicamente la colección de controles durante la ejecución. Estos métodos permiten crear interfaces verdaderamente reactivas donde los usuarios pueden gestionar listas de elementos según sus necesidades.

1 - Agregar elementos con push():

El método push() añade un nuevo control al final del array, siendo la forma más común de expandir dinámicamente un formulario:

@Component({
  selector: 'app-dynamic-phones',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="contactForm">
      <div formArrayName="phones">
        <h3>Teléfonos de contacto</h3>
        @for (phone of phonesArray.controls; track $index) {
          <div>
            <input [formControlName]="$index" type="tel" placeholder="Número">
            <button type="button" (click)="removePhone($index)">Eliminar</button>
          </div>
        }
        <button type="button" (click)="addPhone()">Agregar teléfono</button>
      </div>
    </form>
  `
})
export class DynamicPhonesComponent {
  contactForm: FormGroup;

  constructor(private fb: FormBuilder) {
    this.contactForm = this.fb.group({
      phones: this.fb.array([this.fb.control('')])
    });
  }

  get phonesArray(): FormArray {
    return this.contactForm.get('phones') as FormArray;
  }

  addPhone(): void {
    this.phonesArray.push(this.fb.control(''));
  }

  removePhone(index: number): void {
    this.phonesArray.removeAt(index);
  }
}

2 - Inserción en posiciones específicas con insert():

Para casos donde necesitamos control preciso sobre la posición, el método insert() permite añadir elementos en cualquier índice del array:

addPhoneAtPosition(index: number): void {
  const newPhone = this.fb.control('');
  this.phonesArray.insert(index, newPhone);
}

// Agregar al inicio del array
addPhoneAtBeginning(): void {
  this.phonesArray.insert(0, this.fb.control(''));
}

3 - Eliminación de elementos específicos:

El método removeAt() elimina el control en el índice especificado, ajustando automáticamente los índices posteriores:

removePhone(index: number): void {
  if (this.phonesArray.length > 1) {
    this.phonesArray.removeAt(index);
  }
}

// Eliminar múltiples elementos
removeMultiplePhones(indices: number[]): void {
  // Ordenar índices en orden descendente para evitar problemas de reindexación
  indices.sort((a, b) => b - a);
  indices.forEach(index => this.phonesArray.removeAt(index));
}

Gestión completa de arrays complejos

Para formularios con datos estructurados, la manipulación requiere métodos helper que mantengan la consistencia de la estructura:

@Component({
  selector: 'app-experience-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="profileForm">
      <div formArrayName="experiences">
        <h3>Experiencia laboral</h3>
        @for (exp of experiencesArray.controls; track $index) {
          <fieldset [formGroupName]="$index">
            <legend>Experiencia {{$index + 1}}</legend>
            <div>
              <label>Empresa:</label>
              <input formControlName="company" type="text">
            </div>
            <div>
              <label>Puesto:</label>
              <input formControlName="position" type="text">
            </div>
            <div>
              <label>Fecha inicio:</label>
              <input formControlName="startDate" type="date">
            </div>
            <div>
              <label>Fecha fin:</label>
              <input formControlName="endDate" type="date">
            </div>
            <button type="button" (click)="removeExperience($index)">
              Eliminar experiencia
            </button>
          </fieldset>
        }
        <button type="button" (click)="addExperience()">
          Agregar experiencia
        </button>
        <button type="button" (click)="clearAllExperiences()" 
                [disabled]="experiencesArray.length === 0">
          Limpiar todo
        </button>
      </div>
    </form>
  `
})
export class ExperienceFormComponent {
  profileForm: FormGroup;

  constructor(private fb: FormBuilder) {
    this.profileForm = this.fb.group({
      experiences: this.fb.array([this.createExperienceGroup()])
    });
  }

  private createExperienceGroup(): FormGroup {
    return this.fb.group({
      company: ['', Validators.required],
      position: ['', Validators.required],
      startDate: ['', Validators.required],
      endDate: ['']
    });
  }

  get experiencesArray(): FormArray {
    return this.profileForm.get('experiences') as FormArray;
  }

  addExperience(): void {
    this.experiencesArray.push(this.createExperienceGroup());
  }

  removeExperience(index: number): void {
    if (this.experiencesArray.length > 1) {
      this.experiencesArray.removeAt(index);
    }
  }

  clearAllExperiences(): void {
    this.experiencesArray.clear();
    this.addExperience(); // Mantener al menos un elemento
  }
}

Validación de FormArray

La validación de FormArray opera en dos niveles: validación del array como conjunto y validación de controles individuales. Angular proporciona validadores específicos para arrays:

import { Validators } from '@angular/forms';

constructor(private fb: FormBuilder) {
  this.profileForm = this.fb.group({
    skills: this.fb.array(
      [this.createSkillControl()],
      [Validators.minLength(2), Validators.maxLength(10)] // Validadores del array
    )
  });
}

private createSkillControl(): FormControl {
  return this.fb.control('', [
    Validators.required,
    Validators.minLength(2)
  ]);
}

// Validación personalizada para el array completo
static uniqueSkillsValidator(control: AbstractControl): ValidationErrors | null {
  if (!(control instanceof FormArray)) return null;
  
  const values = control.controls.map(ctrl => ctrl.value?.toLowerCase());
  const duplicates = values.filter((value, index) => values.indexOf(value) !== index);
  
  return duplicates.length > 0 ? { duplicateSkills: true } : null;
}

Manejo de estados de validación

El seguimiento del estado de validación permite proporcionar retroalimentación inmediata al usuario sobre la validez de cada elemento y del conjunto:

@Component({
  selector: 'app-validated-skills',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="skillsForm">
      <div formArrayName="skills">
        <h3>Habilidades (mínimo 2, máximo 5)</h3>
        
        @for (skill of skillsArray.controls; track $index) {
          <div>
            <input [formControlName]="$index" 
                   type="text" 
                   placeholder="Nombre de la habilidad"
                   [class.error]="skill.invalid && skill.touched">
            
            @if (skill.invalid && skill.touched) {
              <div class="error-message">
                @if (skill.errors?.['required']) {
                  <span>La habilidad es obligatoria</span>
                }
                @if (skill.errors?.['minlength']) {
                  <span>Mínimo 2 caracteres</span>
                }
              </div>
            }
            
            <button type="button" 
                    (click)="removeSkill($index)"
                    [disabled]="skillsArray.length <= 2">
              Eliminar
            </button>
          </div>
        }
        
        <button type="button" 
                (click)="addSkill()"
                [disabled]="skillsArray.length >= 5">
          Agregar habilidad
        </button>
        
        @if (skillsArray.invalid && skillsArray.touched) {
          <div class="array-error">
            @if (skillsArray.errors?.['minlength']) {
              <span>Se requieren al menos 2 habilidades</span>
            }
            @if (skillsArray.errors?.['maxlength']) {
              <span>Máximo 5 habilidades permitidas</span>
            }
          </div>
        }
      </div>
    </form>
  `,
  styles: [`
    .error { border-color: #dc3545; }
    .error-message, .array-error { color: #dc3545; font-size: 0.875em; }
  `]
})
export class ValidatedSkillsComponent {
  skillsForm: FormGroup;

  constructor(private fb: FormBuilder) {
    this.skillsForm = this.fb.group({
      skills: this.fb.array(
        [this.createSkillControl(), this.createSkillControl()],
        [Validators.minLength(2), Validators.maxLength(5)]
      )
    });
  }

  private createSkillControl(): FormControl {
    return this.fb.control('', [
      Validators.required,
      Validators.minLength(2)
    ]);
  }

  get skillsArray(): FormArray {
    return this.skillsForm.get('skills') as FormArray;
  }

  addSkill(): void {
    if (this.skillsArray.length < 5) {
      this.skillsArray.push(this.createSkillControl());
    }
  }

  removeSkill(index: number): void {
    if (this.skillsArray.length > 2) {
      this.skillsArray.removeAt(index);
    }
  }

  getSkillErrors(index: number): any {
    return this.skillsArray.at(index)?.errors;
  }

  isSkillInvalid(index: number): boolean {
    const control = this.skillsArray.at(index);
    return !!(control?.invalid && control?.touched);
  }
}

Optimización de rendimiento en manipulación

Para formularios con muchos elementos, es importante considerar el rendimiento durante las operaciones de manipulación:

// Operaciones batch para múltiples cambios
performBatchOperations(): void {
  // Deshabilitar notificaciones temporalmente
  this.skillsArray.disable({ emitEvent: false });
  
  // Realizar múltiples operaciones
  this.skillsArray.push(this.createSkillControl());
  this.skillsArray.push(this.createSkillControl());
  this.skillsArray.removeAt(0);
  
  // Reactivar y emitir eventos
  this.skillsArray.enable({ emitEvent: true });
  this.skillsArray.updateValueAndValidity();
}

// Validación diferida para mejor UX
private setupDeferredValidation(): void {
  this.skillsArray.valueChanges.pipe(
    debounceTime(300), // Esperar 300ms después del último cambio
    distinctUntilChanged()
  ).subscribe(() => {
    this.validateUniqueSkills();
  });
}

La manipulación efectiva de FormArray requiere equilibrar la funcionalidad dinámica con la experiencia del usuario, proporcionando validación inmediata y controles intuitivos para la gestión de elementos.

Alan Sastre - Autor del tutorial

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 estructura y uso básico de FormArray en Angular.
  • Diferenciar entre FormGroup y FormArray y sus aplicaciones.
  • Aprender a crear, agregar, insertar y eliminar controles dinámicamente en un FormArray.
  • Implementar validaciones tanto a nivel de controles individuales como del array completo.
  • Optimizar la manipulación y rendimiento en formularios con listas dinámicas extensas.