Front-end

Angular FormArray : formulaires dynamiques

Angular Formarray Formulaires-Dynamiques Reactive-Forms Formgroup Formcontrol Validation Validation-Croisee Formbuilder Template Front-End Typescript Bonnes-Pratiques
Angular FormArray : formulaires dynamiques

Créez des formulaires dynamiques en Angular avec FormArray : ajouter et supprimer des champs à la volée, liaison au template et validation simple ou croisée.

Pourquoi FormArray ?

Angular Reactive Forms offre trois blocs de construction : FormControl, FormGroup et FormArray. Si les deux premiers couvrent la majorité des formulaires statiques, FormArray est le candidat naturel dès que le nombre de champs dépend de l'utilisateur ou d'une source de données distante.

Cas d'usage typiques en production : formulaire de commande avec n lignes d'articles, liste de participants à un événement, saisie de compétences dans un CV builder, tableau de règles de validation dans un back-office. Sans FormArray, ces scénarios nécessitent de la gestion manuelle d'index, du code fragile, et une duplication de la logique de validation.

FormArray vs FormGroup : FormGroup associe des contrôles à des clés fixes connues à la compilation. FormArray gère un tableau indexé dont la longueur évolue à l'exécution. Les deux se combinent librement.

Position dans l'écosystème Reactive Forms

Classe Rôle Accès valeur
FormControl Contrôle feuille (input, checkbox…) control.value
FormGroup Objet clé→contrôle group.value — objet
FormArray Tableau de contrôles dynamiques array.value — tableau

Créer et configurer un FormArray

Importez ReactiveFormsModule dans votre composant standalone, puis construisez le FormArray soit manuellement, soit via FormBuilder — approche recommandée car plus lisible.

Avec FormBuilder (recommandé)

// phones-form.component.ts
import { Component, inject } from '@angular/core';
import {
  FormBuilder,
  FormArray,
  FormControl,
  Validators,
  ReactiveFormsModule,
} from '@angular/forms';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-phones-form',
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule],
  templateUrl: './phones-form.component.html',
})
export class PhonesFormComponent {
  private fb = inject(FormBuilder);

  // Le FormGroup racine contient un champ "phones" de type FormArray
  form = this.fb.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    phones: this.fb.array([
      this.buildPhone(), // on démarre avec une ligne vide
    ]),
  });

  // Factory : crée un FormGroup représentant une ligne téléphone
  buildPhone(): ReturnType<FormBuilder['group']> {
    return this.fb.group({
      type:   ['mobile', Validators.required],   // 'mobile' | 'home' | 'work'
      number: ['', [Validators.required, Validators.pattern(/^\+?[\d\s\-]{7,15}$/)]],
    });
  }

  // Getter typé pour éviter les casts répétés dans le template
  get phones(): FormArray {
    return this.form.get('phones') as FormArray;
  }
}
Le getter phones typé FormArray est une bonne pratique. Il évite le cast as FormArray partout dans le template et centralise l'accès.

Accès programmatique aux contrôles enfants

// Récupérer un contrôle à l'index 0
const firstPhone = this.phones.at(0); // AbstractControl

// Lire la valeur typée
const allValues = this.phones.value; // Array<{type: string, number: string}>

// Vérifier le statut global
console.log(this.phones.valid);   // true | false
console.log(this.phones.length);  // 1

Lier le FormArray au template

La directive formArrayName connecte le template au FormArray. Chaque élément du tableau utilise ensuite [formGroupName]="i" pour accéder au FormGroup enfant via son index.

<!-- phones-form.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">

  <!-- Champ statique -->
  <div class="mb-3">
    <label class="form-label" for="name">Nom</label>
    <input id="name" formControlName="name" class="form-control"
           [class.is-invalid]="form.get('name')?.invalid && form.get('name')?.touched">
    <div class="invalid-feedback">Nom requis (min. 2 caractères).</div>
  </div>

  <!-- FormArray : chaque ligne est un FormGroup indexé -->
  <div formArrayName="phones">
    <div *ngFor="let phone of phones.controls; let i = index"
         [formGroupName]="i"
         class="row g-2 align-items-end mb-2">

      <div class="col-md-4">
        <label class="form-label">Type</label>
        <select formControlName="type" class="form-select">
          <option value="mobile">Mobile</option>
          <option value="home">Domicile</option>
          <option value="work">Travail</option>
        </select>
      </div>

      <div class="col-md-6">
        <label class="form-label">Numéro</label>
        <input formControlName="number" class="form-control"
               placeholder="+33 6 00 00 00 00"
               [class.is-invalid]="phone.get('number')?.invalid && phone.get('number')?.touched">
        <div class="invalid-feedback">Format invalide (7-15 chiffres).</div>
      </div>

      <div class="col-md-2">
        <button type="button" class="btn btn-outline-danger w-100"
                (click)="removePhone(i)"
                [disabled]="phones.length === 1">
          &times;
        </button>
      </div>

    </div>
  </div>

  <button type="button" class="btn btn-outline-primary mb-3"
          (click)="addPhone()">+ Ajouter un téléphone</button>

  <div class="d-flex gap-2">
    <button type="submit" class="btn btn-primary" [disabled]="form.invalid">Enregistrer</button>
    <button type="button" class="btn btn-secondary" (click)="reset()">Réinitialiser</button>
  </div>

</form>
Attention : utilisez [formGroupName]="i" (binding dynamique) et non formGroupName="0" (chaîne statique). L'index varie à chaque ajout/suppression.

Ajouter et supprimer dynamiquement

L'API de FormArray expose cinq méthodes essentielles pour manipuler le tableau à l'exécution.

Méthode Effet Quand l'utiliser
push(ctrl) Ajoute en fin de tableau Bouton "+ Ajouter"
insert(i, ctrl) Insère à l'index i Duplication de ligne, glisser-déposer
removeAt(i) Supprime à l'index i Bouton "Supprimer" sur chaque ligne
setControl(i, ctrl) Remplace le contrôle à l'index i Changement de type de ligne
clear() Supprime tous les contrôles Reset complet ou rechargement de données
// phones-form.component.ts (suite)
addPhone(): void {
  this.phones.push(this.buildPhone());
}

removePhone(index: number): void {
  // Garde au moins une ligne
  if (this.phones.length > 1) {
    this.phones.removeAt(index);
  }
}

reset(): void {
  this.phones.clear();
  this.phones.push(this.buildPhone()); // repart d'une ligne vide
  this.form.get('name')?.reset();
}

onSubmit(): void {
  if (this.form.valid) {
    console.log(this.form.value);
    // { name: 'Alice', phones: [{type:'mobile', number:'+33600000000'}] }
  }
}

Limite minimale et maximale

// Désactiver "Supprimer" si une seule ligne reste (dans le template)
// [disabled]="phones.length === 1"

// Désactiver "+ Ajouter" si 5 lignes max
// [disabled]="phones.length >= 5"

// Ou centraliser avec des getters
get canAddPhone(): boolean { return this.phones.length < 5; }
get canRemovePhone(): boolean { return this.phones.length > 1; }

Validation simple et croisée

La validation d'un FormArray s'effectue à trois niveaux : au niveau de chaque FormControl, au niveau de chaque FormGroup enfant (validation croisée entre champs d'une ligne), et au niveau du FormArray lui-même (règles globales sur l'ensemble du tableau).

Validation au niveau du FormControl

// Déjà vu dans buildPhone() — Validators classiques
number: ['', [Validators.required, Validators.pattern(/^\+?[\d\s\-]{7,15}$/)]],

Validator cross-champ sur chaque ligne

// Interdire le numéro "0000000000" si le type est "work"
function noPlaceholderWorkPhone(group: AbstractControl): ValidationErrors | null {
  const type   = group.get('type')?.value;
  const number = group.get('number')?.value;
  if (type === 'work' && number === '0000000000') {
    return { placeholderWorkPhone: true };
  }
  return null;
}

// Passer en option validators du FormGroup enfant
buildPhone(): FormGroup {
  return this.fb.group(
    {
      type:   ['mobile', Validators.required],
      number: ['', [Validators.required, Validators.pattern(/^\+?[\d\s\-]{7,15}$/)]],
    },
    { validators: noPlaceholderWorkPhone }, // validator de groupe
  );
}

Validator global sur le FormArray

// S'assurer qu'au moins 1 téléphone "mobile" est défini
function atLeastOneMobile(array: AbstractControl): ValidationErrors | null {
  const fa = array as FormArray;
  const hasMobile = fa.controls.some(
    ctrl => ctrl.get('type')?.value === 'mobile',
  );
  return hasMobile ? null : { noMobile: true };
}

// À passer en deuxième argument de fb.array()
phones: this.fb.array([this.buildPhone()], { validators: atLeastOneMobile }),

Afficher les erreurs de validator de groupe dans le template

<!-- Erreur cross-champ sur la ligne i -->
<div *ngIf="phones.at(i).errors?.['placeholderWorkPhone']"
     class="alert alert-warning mt-1 py-1">
  Un numéro de travail ne peut pas être "0000000000".
</div>

<!-- Erreur globale du FormArray -->
<div *ngIf="phones.errors?.['noMobile'] && phones.touched"
     class="alert alert-danger mt-2">
  Au moins un numéro mobile est requis.
</div>
Les erreurs d'un validator de FormGroup ou FormArray se lisent via .errors sur le groupe/tableau, pas sur les contrôles enfants.

FormArray de FormGroup imbriqués

Les formulaires de commande complexes nécessitent souvent plusieurs champs par ligne (article, quantité, remise, unité de mesure). On structure alors chaque ligne comme un FormGroup dans le FormArray.

// order-form.component.ts — exemple commande e-commerce
interface OrderLine {
  productId: string;
  quantity:  number;
  discount:  number;
}

@Component({
  selector: 'app-order-form',
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule],
  templateUrl: './order-form.component.html',
})
export class OrderFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    customerId: ['', Validators.required],
    lines: this.fb.array([this.buildLine()]),
  });

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

  buildLine(): FormGroup {
    return this.fb.group({
      productId: ['', Validators.required],
      quantity:  [1, [Validators.required, Validators.min(1), Validators.max(999)]],
      discount:  [0, [Validators.min(0), Validators.max(100)]],
    });
  }

  addLine(): void { this.lines.push(this.buildLine()); }
  removeLine(i: number): void { this.lines.removeAt(i); }

  // Calculer le total de toutes les lignes en temps réel
  get total(): number {
    return this.lines.controls.reduce((sum, ctrl) => {
      const qty  = ctrl.get('quantity')?.value  ?? 0;
      const disc = ctrl.get('discount')?.value ?? 0;
      // Dans un vrai projet, on multiplierait par le prix unitaire
      return sum + qty * (1 - disc / 100);
    }, 0);
  }
}

Template de la liste de lignes commande

<div formArrayName="lines">
  <div *ngFor="let line of lines.controls; let i = index"
       [formGroupName]="i"
       class="row g-2 mb-2 align-items-end">

    <div class="col-md-5">
      <label class="form-label">Produit</label>
      <input formControlName="productId" class="form-control"
             placeholder="SKU-XXXXX"
             [class.is-invalid]="line.get('productId')?.invalid && line.get('productId')?.touched">
    </div>

    <div class="col-md-3">
      <label class="form-label">Quantité</label>
      <input type="number" formControlName="quantity" class="form-control" min="1" max="999">
    </div>

    <div class="col-md-2">
      <label class="form-label">Remise %</label>
      <input type="number" formControlName="discount" class="form-control" min="0" max="100">
    </div>

    <div class="col-md-2">
      <button type="button" class="btn btn-outline-danger w-100"
              (click)="removeLine(i)" [disabled]="lines.length === 1">&times;</button>
    </div>

  </div>
</div>

<div class="d-flex justify-content-between align-items-center mt-2">
  <button type="button" class="btn btn-outline-success" (click)="addLine()">
    + Ajouter une ligne
  </button>
  <strong>Total : {{ total | number:'1.2-2' }}</strong>
</div>

Pré-remplir depuis une API

Un cas très fréquent en production : charger les données depuis un endpoint REST, puis peupler le FormArray. L'approche correcte consiste à vider le tableau avec clear() puis à utiliser push() en boucle — ou setValue() si la longueur est fixe.

// Charger et peupler depuis un service
ngOnInit(): void {
  this.contactService.getContact(this.id).subscribe(contact => {
    this.form.get('name')?.setValue(contact.name);
    this.populatePhones(contact.phones);
  });
}

private populatePhones(phones: Array<{type: string; number: string}>): void {
  this.phones.clear(); // vider l'état précédent

  phones.forEach(phone => {
    const group = this.buildPhone(); // créer un FormGroup vierge
    group.patchValue(phone);          // le remplir avec les données
    this.phones.push(group);
  });

  // Alternative avec setValue() si on connaît la longueur exacte à l'avance
  // this.phones.setValue(phones);
}

patchValue vs setValue pour un FormArray

// patchValue() : tolère les champs manquants dans l'objet source
this.phones.patchValue([{type: 'mobile'}]); // number reste à ''

// setValue() : requiert TOUS les champs pour CHAQUE contrôle
this.phones.setValue([{type: 'mobile', number: '+33600000000'}]);
// Lance une erreur si la longueur diffère ou si un champ manque
Préférez patchValue() en général : il est plus robuste face aux évolutions de schéma API. Réservez setValue() aux cas où vous voulez une assertion stricte sur la structure.

FormArray et Signals Angular 2026

Avec l'arrivée de Signal Forms en Angular 22 (stable), il est possible de s'abonner aux changements d'un FormArray via le Signal valueChanges ou d'utiliser toSignal() de @angular/core/rxjs-interop pour intégrer le flux RxJS dans le graphe réactif.

// Convertir les changements du FormArray en Signal (Angular 16+)
import { toSignal } from '@angular/core/rxjs-interop';

@Component({ /* ... */ })
export class PhonesFormComponent {
  // ...

  // Signal qui se met à jour à chaque changement du FormArray
  phonesValue = toSignal(this.phones.valueChanges, {
    initialValue: this.phones.value,
  });

  // Signal calculé : compter les mobiles en temps réel
  mobileCount = computed(
    () => this.phonesValue().filter((p: any) => p.type === 'mobile').length,
  );
}

Signal Forms (Angular 22) — aperçu

// Avec Signal Forms stables (Angular 22+)
// La syntaxe classique reste supportée — Signal Forms est optionnel
import { signalGroup, signalArray, signalControl } from '@angular/forms';

// FormArray signal-first
const phones = signalArray([
  signalGroup({
    type:   signalControl('mobile'),
    number: signalControl(''),
  }),
]);

// Accès direct en tant que Signal (pas de .valueChanges nécessaire)
// phones.value() → [{type: 'mobile', number: ''}]
signalArray() fait partie de Signal Forms Angular 22. Pour les projets sur Angular 17-21, utilisez toSignal(formArray.valueChanges) comme pont.

Patterns avancés et réutilisabilité

À mesure que les formulaires grandissent, on extrait la logique dans des composants dédiés et des fonctions factory partagées.

Pattern : composant de ligne réutilisable

// phone-line.component.ts — accepte un FormGroup en @Input
@Component({
  selector: 'app-phone-line',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <div [formGroup]="group" class="row g-2">
      <div class="col-md-4">
        <select formControlName="type" class="form-select">
          <option value="mobile">Mobile</option>
          <option value="home">Domicile</option>
          <option value="work">Travail</option>
        </select>
      </div>
      <div class="col-md-8">
        <input formControlName="number" class="form-control">
      </div>
    </div>
  `,
})
export class PhoneLineComponent {
  @Input({ required: true }) group!: FormGroup;
}
<!-- Utilisation dans le parent -->
<div formArrayName="phones">
  <app-phone-line
    *ngFor="let line of phones.controls; let i = index"
    [formGroupName]="i"
    [group]="line as FormGroup"
  />
</div>

Pattern : factory centralisée

// form-factories.ts — partageable entre composants
export function createPhoneGroup(fb: FormBuilder): FormGroup {
  return fb.group({
    type:   ['mobile', Validators.required],
    number: ['', [Validators.required, Validators.pattern(/^\+?[\d\s\-]{7,15}$/)]],
  });
}

export function createOrderLineGroup(fb: FormBuilder): FormGroup {
  return fb.group({
    productId: ['', Validators.required],
    quantity:  [1, [Validators.min(1), Validators.max(999)]],
    discount:  [0, [Validators.min(0), Validators.max(100)]],
  });
}

Pattern : ControlValueAccessor pour FormArray personnalisé

// Pour exposer un FormArray comme un FormControl dans un parent
// Implémentez ControlValueAccessor + NG_VALUE_ACCESSOR
@Component({
  selector: 'app-tags-input',
  // ...
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => TagsInputComponent),
    multi: true,
  }],
})
export class TagsInputComponent implements ControlValueAccessor {
  tagsArray = new FormArray<FormControl<string>>([]);

  writeValue(tags: string[]): void {
    this.tagsArray.clear();
    tags.forEach(t => this.tagsArray.push(new FormControl(t, { nonNullable: true })));
  }

  registerOnChange(fn: (v: string[]) => void): void {
    this.tagsArray.valueChanges.subscribe(fn);
  }

  registerOnTouched(fn: () => void): void { /* ... */ }
}
  • Extrayez la factory buildLine() dans un fichier partagé dès qu'elle est utilisée dans 2+ composants
  • Créez un composant de ligne si le template de ligne dépasse 50 lignes
  • Utilisez ControlValueAccessor pour rendre un FormArray réutilisable comme contrôle atomique
  • Évitez le cast as FormGroup dans le template — préférez des getters typés dans la classe

Tester un FormArray

Les FormArray se testent facilement en unitaire avec Jasmine/Jest, sans DOM ni TestBed complet. On instancie directement le FormGroup racine et on manipule le tableau.

// phones-form.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule, FormArray } from '@angular/forms';
import { PhonesFormComponent } from './phones-form.component';

describe('PhonesFormComponent — FormArray', () => {
  let component: PhonesFormComponent;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [PhonesFormComponent, ReactiveFormsModule],
    });
    component = TestBed.createComponent(PhonesFormComponent).componentInstance;
  });

  it('démarre avec 1 ligne téléphone', () => {
    expect(component.phones.length).toBe(1);
  });

  it('addPhone() ajoute une ligne', () => {
    component.addPhone();
    expect(component.phones.length).toBe(2);
  });

  it('removePhone(0) supprime la ligne — sauf si unique', () => {
    component.addPhone(); // 2 lignes
    component.removePhone(0);
    expect(component.phones.length).toBe(1);
    component.removePhone(0); // tente de supprimer la dernière
    expect(component.phones.length).toBe(1); // bloqué
  });

  it('formulaire invalide si un numéro est vide', () => {
    component.phones.at(0).get('number')?.setValue('');
    expect(component.form.valid).toBeFalse();
  });

  it('formulaire valide avec données correctes', () => {
    component.form.get('name')?.setValue('Alice');
    component.phones.at(0).get('number')?.setValue('+33600000000');
    expect(component.form.valid).toBeTrue();
  });

  it('validator global détecte absence de mobile', () => {
    component.phones.at(0).get('type')?.setValue('work');
    // Si le validator atLeastOneMobile est actif sur le FormArray
    expect(component.phones.errors?.['noMobile']).toBeTruthy();
  });
});
Testez séparément : (1) les validators purs (fonctions) sans TestBed, (2) le comportement du composant avec TestBed minimal, (3) le template avec un test d'intégration. Cette séparation garde les suites rapides.

Conclusion

FormArray comble un vide que FormGroup ne peut pas remplir : la gestion de listes dynamiques où le nombre d'éléments n'est pas connu à la compilation. Sa maîtrise est indispensable pour construire des formulaires de production — commandes, profils multi-contacts, configuration à lignes variables.

Les principes retenus dans cet article se résument en cinq points :

  • Utilisez FormBuilder.array() pour une syntaxe concise et lisible
  • Exposez des getters typés (get phones(): FormArray) — jamais de cast inline dans le template
  • Validez à trois niveaux : contrôle, groupe enfant, tableau global
  • Peuplez depuis une API avec clear() + boucle push() — ne jamais réassigner la référence
  • Bridgez vers les Signals avec toSignal(formArray.valueChanges) pour alimenter des computed()

Avec Angular 22 et Signal Forms désormais stables, l'API signalArray() offre une alternative signal-first qui élimine le besoin de valueChanges. La migration reste incrémentale : FormArray classique et Signal Forms coexistent dans un même projet.

Articles complémentaires : Angular Best Practices — Reactive Forms, Angular Signal Forms — validation moderne, Angular 22 — toutes les nouveautés.

Questions fréquentes

FormArray est un contrôle Angular Reactive Forms qui gère un tableau dynamique de FormControl ou FormGroup. Utilisez-le pour des champs dont le nombre varie à l'exécution : liste de téléphones, lignes de commande, participants à un événement.

Utilisez this.formArray.push(new FormControl()) pour ajouter et this.formArray.removeAt(index) pour supprimer. Les méthodes insert(index, ctrl) et clear() permettent l'insertion positionnée et la réinitialisation complète.

Appliquez des validators au niveau de chaque FormControl (Validators.required, Validators.email…) et des validators cross-champ à chaque FormGroup enfant via l'option {validators: myFn}. Ajoutez un validator global au FormArray lui-même pour des règles comme "minimum 2 éléments".

FormGroup associe des contrôles à des clés fixes connues à la compilation. FormArray gère un tableau indexé dont la longueur change dynamiquement. Combinez les deux : un FormArray de FormGroup permet des lignes complexes avec plusieurs champs chacune.

Partager