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.
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;
}
}
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">
×
</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>
[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>
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">×</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
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
ControlValueAccessorpour rendre unFormArrayréutilisable comme contrôle atomique - Évitez le cast
as FormGroupdans 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();
});
});
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()+ bouclepush()— ne jamais réassigner la référence - Bridgez vers les Signals avec
toSignal(formArray.valueChanges)pour alimenter descomputed()
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.