~9 min de lecture
FormArray : 3 pièges qui font mentir tes formulaires dynamiques
TL;DR
new FormArray([]) t'enferme dans un FormArray<never> où plus rien ne rentre, form.get('lines') te rend un AbstractControl sans push ni removeAt, et le cast as FormArray que tu poses pour t'en sortir efface tout le typage. Côté runtime, une ligne désactivée par disable() disparaît de value tant qu'il reste une ligne active : l'array raccourcit, les index se décalent, et Validators.minLength compte moins de lignes que ce que l'utilisateur voit à l'écran. Les réflexes : une factory typée + le générique explicite, form.controls.lines plutôt que get(), getRawValue() pour le payload, et track line (l'instance du contrôle) dans le @for.
Le scénario classique : un éditeur de lignes de facture. L'utilisateur ajoute des lignes, en supprime, le total se recalcule. Un FormArray, donc. Tu commences à coder, et au bout de vingt minutes ton fichier contient trois as FormArray, un as any honteux, et un commentaire // TODO: typer ça.
Ce n'est pas toi le problème. C'est que FormArray cumule trois familles de pièges : des pièges de typage, hérités de la manière dont TypeScript infère les génériques ; des pièges de runtime, hérités du contrat de value ; et un piège de template, hérité de la façon dont @for réutilise ses nœuds. Sur un array, le contrat de value a une conséquence que peu de gens voient venir : les index mentent.
On démonte les trois pièges, puis on assemble la version propre.
Vérifié sur Angular 22.1, au compilateur en mode strict et à l'exécution. Les Typed Forms existent depuis Angular 14 et le control flow
@fordepuis la v17 : les comportements de typage et de validation sont contre-vérifiés à l'identique sur la v17.0, et le libellé exact des erreurs citées est celui de la v22.
Piège 1 : le typage s'évapore à la création et à la lecture
new FormArray([]) : bienvenue dans never
Ton formulaire démarre vide, l'utilisateur ajoutera ses lignes. Premier réflexe :
import { FormArray } from '@angular/forms';
const lines = new FormArray([]);
Ça compile. Mais survole la variable : TypeScript n'a aucun élément pour inférer le type des contrôles, donc il infère FormArray<never>. Et un array de never, c'est un array où rien ne peut jamais entrer. Le push qui suit ne compile pas :
lines.push(new FormControl(''));
// error TS2345: Argument of type 'FormControl<string | null>' is not
// assignable to parameter of type 'never[]'.
La variante FormBuilder échoue différemment, et c'est presque pire : fb.array([]) infère FormArray<FormControl<unknown>>. Là, le push compile, et c'est ta value qui devient unknown[] : le problème explose plus loin, au moment de construire le payload.
form.get('lines') : la valeur est typée, l'API est perdue
Deuxième source de casts : la lecture. Tu as pris l'habitude de form.get('...'), donc :
const lines = form.get('lines');
lines.push(buildLine());
// error TS18047: 'lines' is possibly 'null'.
// error TS2339: Property 'push' does not exist on type
// 'AbstractControl<{ label: string; qty: number; }[], ...>'.
Note le détail : depuis les Typed Forms, get() infère bien le type de la valeur à partir du chemin. Ce que tu perds, c'est la classe du contrôle : un AbstractControl n'a ni push, ni removeAt, ni clear. D'où le cast réflexe :
const lines = form.get('lines') as FormArray;
Et là, c'est la double peine : FormArray sans générique, c'est FormArray<any>. Tu viens de troquer une erreur de compilation contre du any silencieux sur tout ce qui touche à tes lignes.
Le correctif : factory typée, générique explicite, controls
Trois gestes, zéro cast :
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
type LineForm = FormGroup<{
label: FormControl<string>;
qty: FormControl<number>;
}>;
@Component({
selector: 'app-invoice-lines',
imports: [ReactiveFormsModule],
templateUrl: './invoice-lines.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoiceLines {
private readonly fb = inject(FormBuilder);
private buildLine(label = '', qty = 1): LineForm {
return this.fb.nonNullable.group({ label, qty });
}
protected readonly form = this.fb.group({
lines: this.fb.array<LineForm>([this.buildLine()]),
});
protected addLine(): void {
this.form.controls.lines.push(this.buildLine());
}
protected removeLine(index: number): void {
this.form.controls.lines.removeAt(index);
}
protected save(): void {
const payload = this.form.controls.lines.getRawValue();
// PATCH payload : et le getRawValue() n'est pas un détail, c'est le piège 2.
}
}
La factory buildLine() fixe le type une fois pour toutes, le générique array<LineForm> débloque le cas vide, et form.controls.lines garde la classe FormArray avec son API complète. Pousser un contrôle de la mauvaise forme ne compile plus. C'est exactement le même réflexe que celui qui te fait préférer form.controls.x à form.get('x') sur un FormGroup, avec un enjeu plus élevé : sur un array, le cast de secours est quasi systématique, donc la dette de any aussi.
Piège 2 : la ligne disabled qui raccourcit ton array
Tu connais peut-être déjà le contrat sur FormGroup : un champ désactivé disparaît de form.value (le piège 1 de l'article sur les Typed Forms). Sur un FormArray, la clé ne disparaît pas : l'élément disparaît. Démonstration, avec la factory buildLine() du piège 1, appelée ici hors de la classe :
const lines = new FormArray([
buildLine('a', 1),
buildLine('b', 2),
buildLine('c', 3),
]);
lines.at(1).disable();
console.log(lines.value);
// [{ label: 'a', qty: 1 }, { label: 'c', qty: 3 }]
console.log(lines.getRawValue());
// [{ label: 'a', qty: 1 }, { label: 'b', qty: 2 }, { label: 'c', qty: 3 }]
Trois contrôles, trois lignes à l'écran, et value.length === 2. Conséquences en cascade :
- Les index mentent.
lines.value[1]ne correspond plus àlines.at(1). Tout code qui croisevalueetat()par index, typiquement un message d'erreur qui pointe la ligne fautive par son index, vise la mauvaise ligne. - Ton payload perd des lignes. Le PATCH part avec 2 lignes au lieu de 3, et côté backend, ça ressemble à une suppression demandée par l'utilisateur.
- Tes validateurs comptent faux. C'est le plus sournois : un validateur intégré posé sur l'array (
required,minLength,maxLength) litcontrol.value, donc l'array raccourci.
Ce dernier point mérite sa démonstration. Validators.minLength fonctionne sur un FormArray (il lit value.length), et c'est l'outil naturel pour exiger "au moins N lignes". À un détail près, sur lequel on revient après la démo :
const lines = new FormArray(
[buildLine('a', 1), buildLine('b', 2)],
{ validators: Validators.minLength(2) },
);
lines.valid; // true, 2 lignes
lines.at(0).disable();
lines.valid; // false
lines.errors; // { minlength: { requiredLength: 2, actualLength: 1 } }
Deux lignes à l'écran, actualLength: 1. L'utilisateur voit un formulaire complet et un bouton de soumission grisé, sans aucun champ en erreur. Le ticket support s'écrit tout seul.
Le détail promis : minLength ne protège pas du formulaire vide. Il ignore les valeurs vides, et un array de longueur 0 en est une : sur un FormArray sans aucune ligne, minLength(1) rend null et l'array est valide. Pour "au moins une ligne", c'est Validators.required qu'il te faut : lui considère un array de longueur 0 comme vide et rend { required: true }. Pour "au moins N lignes", combine les deux : required ferme le cas vide, minLength(N) compte le reste.
Le réflexe principal reste le même que sur FormGroup, en plus impératif : tout ce qui part vers une API ou croise des index passe par getRawValue(). Et si tu utilises disable() pour geler des lignes existantes (lignes facturées, lignes verrouillées par un autre processus), sache que tes validateurs d'array intégrés raisonnent sur les lignes actives uniquement. Désactive la dernière, et l'array bascule lui-même en DISABLED : sa value contient de nouveau toutes les lignes et plus aucun validateur ne tourne, comme un FormGroup entièrement désactivé (déjà croisé sur les Typed Forms). Et si une règle doit compter toutes les lignes, désactivées comprises : un validateur ne tourne que sur un contrôle actif, et un groupe dont tous les enfants sont désactivés passe en DISABLED comme l'array. Un validateur maison comptant controls.length ne tient donc que sur un parent qui garde un autre champ actif ; ici, les lignes sont tout le formulaire, donc refais le compte dans save().
Piège 3 : le track qui détruit la mauvaise ligne
Reste le template. Le @for exige un track, et sur un FormArray le choix est piégeux parce que l'évidence ($index) est fausse et que la bonne réponse (l'instance du contrôle) ressemble à une erreur.
<!-- Before : l'évidence fausse -->
@for (line of form.controls.lines.controls; track $index) {
...
}
track $index casse dès qu'on supprime une ligne au milieu, et plus gravement que le focus perdu que décrit l'article sur les erreurs de track. Après un removeAt(1), Angular voit les mêmes clés 0..n-2 et détruit le dernier nœud DOM. Sur des bindings de propriété classiques, les lignes survivantes seraient au moins réassociées aux bonnes données. Mais formGroupName et formControlName ne sont pas des bindings de propriété : chaque directive garde le contrôle résolu au moment de sa création, et un removeAt sur un array imbriqué ne les prévient pas. Mesuré sur Angular 22.1, avec trois lignes a, b, c : après suppression de la ligne b, l'écran affiche a, b. La deuxième ligne du DOM reste branchée sur le contrôle supprimé : elle affiche une donnée qui n'existe plus, ce que l'utilisateur y tape n'arrive jamais dans la value du formulaire, et un setValue sur le vrai contrôle ne redessine rien.
La règle du même article dit que track doit donner "une identité stable à chaque item". Or ici, l'identité stable existe déjà : le contrôle lui-même. Un FormGroup poussé dans l'array est une référence qui ne change jamais jusqu'au removeAt. Ce n'est pas un objet recréé à chaque cycle comme une ligne sortie d'un fetch : c'est précisément l'exception qu'il prévoyait pour track item, le cas où la stabilité de la référence, c'est toi qui la garantis. Voilà le template qui complète la classe du piège 1 :
<!-- After : l'identité, c'est le contrôle -->
<form [formGroup]="form" (ngSubmit)="save()">
<div formArrayName="lines">
@for (line of form.controls.lines.controls; track line; let i = $index) {
<div [formGroupName]="i">
<input formControlName="label" />
<input type="number" formControlName="qty" />
<button type="button" (click)="removeLine(i)">Supprimer</button>
</div>
}
</div>
<button type="button" (click)="addLine()">Ajouter une ligne</button>
<button type="submit">Enregistrer</button>
</form>
Deux remarques :
track lineet[formGroupName]="i"cohabitent sans contradiction :tracksert au diffing du DOM (la comparaison avant/après qui décide quels nœuds survivent),formGroupNamesert au binding (quel contrôle derrière ce bloc), etiest réévalué après chaque changement de l'array.- Itérer sur
form.controls.lines.controlsplutôt que survaluegarde le lien direct ligne affichée / contrôle. Itérer survalue, en plus de recréer l'array et ses objets à chaque frappe, te remet dans le piège 2 : une ligne désactivée disparaîtrait de l'affichage.
Classe du piège 1, réflexes getRawValue() du piège 2, template du piège 3 : c'est l'éditeur complet, sans un seul cast.
Et les Signal Forms ?
Si tu démarres un formulaire neuf sur Angular 22 : les Signal Forms sont stables et gèrent les listes dynamiques nativement. On a couvert la migration ici. Mais la réalité des codebases, ce sont des années de Reactive Forms, et un FormArray bien typé se migre beaucoup mieux qu'un FormArray<any> plein de casts. Assainir d'abord, migrer ensuite.
Récap actionnable
- Jamais
new FormArray([])nu : TypeScript infèreFormArray<never>(ouFormArray<FormControl<unknown>>viafb.array([])). Toujours une factory typée + le générique explicite :fb.array<LineForm>([]). form.controls.lines, pasform.get('lines'):get()garde le type de la valeur mais rend unAbstractControlsanspush/removeAt, et le castas FormArrayqui suit est unFormArray<any>.getRawValue()pour le payload et pour tout code à base d'index : une ligne désactivée pardisable()disparaît devalue, l'array raccourcit etvalue[i]ne correspond plus àat(i).- Les validateurs intégrés d'array lisent
value:minLength(2)avec une ligne désactivée sur deux rendactualLength: 1. Tes règles de longueur portent sur les lignes actives, et plus aucune ne tourne si toutes les lignes sont désactivées (l'array passe enDISABLED). Si tes lignes gelées doivent compter : validateur maison qui litlines.controls.length, sur un groupe parent qui garde un champ actif, sinon un contrôle danssave(). requiredpour "au moins une ligne" :minLengthignore les valeurs vides, doncminLength(1)sur un array vide rendnullet laisse passer le formulaire. Pour "au moins N lignes" :required+minLength(N).track linedans le@for, jamais$index: l'instance du contrôle est l'identité stable par construction ;$indexdétruit le mauvais nœud DOM à chaque suppression au milieu et laisse une ligne branchée sur le contrôle supprimé, affichage périmé et saisie perdue.
Un FormArray honnête, c'est trois décisions de typage prises une fois (factory, générique, controls) et deux réflexes que le compilateur ne vérifiera jamais pour toi : getRawValue() pour le payload, track line pour le DOM.