Appearance
FormGroup imbriqués et FormArray
Ce tutoriel explique comment structurer des formulaires Angular complexes avec plusieurs niveaux de FormGroup et des FormArray pour gérer des listes dynamiques. Vous apprendrez à organiser vos données hiérarchiques, à ajouter ou retirer des éléments à la volée et à valider chaque niveau indépendamment.
Imaginez un formulaire d'inscription à une conférence. Vous devez collecter les informations personnelles (nom, email), plusieurs adresses (domicile, travail), et une liste de sessions à suivre. Chaque adresse a sa propre structure (rue, ville, code postal), et l'utilisateur peut ajouter autant de sessions qu'il le souhaite. C'est exactement le type de structure que nous allons modéliser avec des FormGroup imbriqués et des FormArray.
Comprendre la hiérarchie des formulaires
Réflexion
Un FormGroup peut contenir d'autres FormGroup (imbrication) et des FormArray (listes dynamiques). Cette architecture reflète la structure de vos données : un objet utilisateur contient un objet adresse, qui lui-même peut contenir plusieurs numéros de téléphone.
Nous allons construire un formulaire en trois étapes :
- Un
FormGroupracine pour l'utilisateur. - Des
FormGroupimbriqués pour les adresses. - Un
FormArraypour les sessions sélectionnées.
Comment fonctionne l'imbrication
Réflexion
L'imbrication crée une hiérarchie où chaque niveau est isolé mais accessible depuis le parent. Voici comment Angular gère cette structure :
Structure hiérarchique :
FormGroup (racine)
├── FormControl: firstName
├── FormControl: lastName
├── FormGroup (address) ← IMBRIQUÉ
│ ├── FormControl: street
│ ├── FormControl: city
│ └── FormControl: postalCode
└── FormArray (addresses) ← DYNAMIQUE
├── FormGroup [0]
│ ├── FormControl: street
│ └── FormControl: city
└── FormGroup [1]
├── FormControl: street
└── FormControl: cityDans le code TypeScript :
ts
const form = this.formBuilder.group({
// Niveau 0 : FormGroup racine
firstName: [''], // FormControl direct
lastName: [''], // FormControl direct
// Niveau 1 : FormGroup imbriqué (un seul)
address: this.formBuilder.group({
street: [''], // FormControl dans le FormGroup imbriqué
city: ['']
}),
// Niveau 1 : FormArray (plusieurs éléments)
addresses: this.formBuilder.array([
// Chaque élément du tableau est un FormGroup
this.formBuilder.group({
street: [''],
city: ['']
})
])
});Dans le template :
[formGroup]="form": lie le FormGroup racineformControlName="firstName": accède directement au niveau racine<div formGroupName="address">: descend d'un niveau vers le FormGroup imbriquéformControlName="street": accède au contrôle dans le FormGroup imbriqué<div formArrayName="addresses">: descend vers le FormArray[formGroupName]="i": accède à un élément spécifique du FormArray (qui est lui-même un FormGroup)
Navigation dans la hiérarchie
Pour accéder à un contrôle à n'importe quel niveau, utilisez la notation par points :
ts
// Niveau racine
this.form().get('firstName')?.value;
// Niveau 1 : FormGroup imbriqué
this.form().get('address')?.get('street')?.value;
// ou en une seule fois
this.form().get('address.street')?.value;
// Niveau 1 : FormArray, puis élément [0], puis contrôle
this.form().get('addresses')?.get('0')?.get('street')?.value;
// ou en une seule fois
this.form().get('addresses.0.street')?.value;
// Valider un niveau spécifique
(this.form().get('address') as FormGroup).valid;
(this.form().get('addresses') as FormArray).valid;Imbrication à plusieurs niveaux
Vous pouvez imbriquer autant de niveaux que nécessaire :
ts
const form = this.formBuilder.group({
user: this.formBuilder.group({
// Niveau 1
personalInfo: this.formBuilder.group({
// Niveau 2
firstName: [''],
lastName: ['']
}),
contact: this.formBuilder.group({
// Niveau 2
email: [''],
phone: this.formBuilder.group({
// Niveau 3
countryCode: [''],
number: ['']
})
})
})
});Dans le template, vous devez descendre niveau par niveau :
html
<form [formGroup]="form">
<div formGroupName="user">
<div formGroupName="personalInfo">
<input formControlName="firstName" />
</div>
<div formGroupName="contact">
<input formControlName="email" />
<div formGroupName="phone">
<input formControlName="countryCode" />
<input formControlName="number" />
</div>
</div>
</div>
</form>Respectez l'ordre des directives
Dans le template, vous devez toujours déclarer les formGroupName ou formArrayName dans l'ordre de la hiérarchie. Un formControlName ne peut être utilisé qu'après avoir déclaré son FormGroup parent.
Créer un FormGroup avec des groupes imbriqués
Réflexion
Pour représenter un utilisateur avec une adresse, nous créons un FormGroup parent qui contient un autre FormGroup pour l'adresse. Chaque niveau peut avoir ses propres validateurs et valeurs par défaut.
ts
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
const form = this.formBuilder.group({
firstName: [''],
lastName: [''],
address: this.formBuilder.group({
street: [''],
city: [''],
postalCode: ['']
})
});Dans le template, utilisez formGroupName pour lier un FormGroup imbriqué :
html
<form [formGroup]="form">
<input formControlName="firstName" />
<input formControlName="lastName" />
<div formGroupName="address">
<input formControlName="street" />
<input formControlName="city" />
<input formControlName="postalCode" />
</div>
</form>Gérer des listes dynamiques avec FormArray
Réflexion
Un FormArray permet d'ajouter ou retirer des éléments dynamiquement. Chaque élément peut être un FormControl simple ou un FormGroup complet selon vos besoins.
ts
import { FormArray, FormBuilder, FormGroup } from '@angular/forms';
const form = this.formBuilder.group({
sessions: this.formBuilder.array([
this.formBuilder.group({
title: [''],
duration: [0]
})
])
});
// Ajouter une session
addSession(): void {
const sessions = this.form.get('sessions') as FormArray;
sessions.push(
this.formBuilder.group({
title: [''],
duration: [0]
})
);
}
// Retirer une session
removeSession(index: number): void {
const sessions = this.form.get('sessions') as FormArray;
sessions.removeAt(index);
}Dans le template, utilisez formArrayName et @for pour itérer. Important : FormArray n'est pas directement itérable, vous devez utiliser .controls :
html
<div formArrayName="sessions">
@for (session of sessionsArray().controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input formControlName="title" />
<input formControlName="duration" type="number" />
<button type="button" (click)="removeSession(i)">Remove</button>
</div>
}
<button type="button" (click)="addSession()">Add session</button>
</div>Exemple complet : formulaire de conférence
Réflexion
Nous combinons FormGroup imbriqués et FormArray pour créer un formulaire complet. L'utilisateur peut saisir ses informations personnelles, ajouter plusieurs adresses et sélectionner plusieurs sessions.
ts
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal
} from '@angular/core';
import {
FormArray,
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators
} from '@angular/forms';
/**
* **Objectif** : gérer un formulaire complexe avec groupes imbriqués et tableaux dynamiques.
* **Conception** : séparer les informations personnelles, adresses et sessions en sections distinctes pour la clarté.
* @example
* <app-conference-form />
*/
@Component({
selector: 'app-conference-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<h1>Conference Registration</h1>
<form [formGroup]="form()" (ngSubmit)="submit()">
<section>
<h2>Personal Information</h2>
<label for="firstName">First name</label>
<input id="firstName" formControlName="firstName" />
<label for="lastName">Last name</label>
<input id="lastName" formControlName="lastName" />
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" />
</section>
<section formArrayName="addresses">
<h2>Addresses</h2>
@for (address of addressesArray().controls; track $index; let i = $index) {
<div [formGroupName]="i">
<h3>Address {{ i + 1 }}</h3>
<label [for]="'street-' + i">Street</label>
<input [id]="'street-' + i" formControlName="street" />
<label [for]="'city-' + i">City</label>
<input [id]="'city-' + i" formControlName="city" />
<label [for]="'postalCode-' + i">Postal code</label>
<input [id]="'postalCode-' + i" formControlName="postalCode" />
<button type="button" (click)="removeAddress(i)">Remove</button>
</div>
}
<button type="button" (click)="addAddress()">Add address</button>
</section>
<section formArrayName="sessions">
<h2>Sessions</h2>
@for (session of sessionsArray().controls; track $index; let i = $index) {
<div [formGroupName]="i">
<label [for]="'session-title-' + i">Title</label>
<input [id]="'session-title-' + i" formControlName="title" />
<label [for]="'session-duration-' + i">Duration (minutes)</label>
<input
[id]="'session-duration-' + i"
type="number"
formControlName="duration"
/>
<button type="button" (click)="removeSession(i)">Remove</button>
</div>
}
<button type="button" (click)="addSession()">Add session</button>
</section>
<button type="submit" [disabled]="form().invalid">Submit</button>
</form>
@if (lastSubmission()) {
<section>
<h2>Submitted data</h2>
<pre>{{ lastSubmission() }}</pre>
</section>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConferenceFormComponent {
private readonly formBuilder = inject(FormBuilder);
readonly form = signal(
this.formBuilder.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
addresses: this.formBuilder.array([
this.createAddressGroup()
]),
sessions: this.formBuilder.array([
this.createSessionGroup()
])
})
);
readonly lastSubmission = signal<string | null>(null);
/**
* **Objectif** : exposer le FormArray des adresses pour la liaison dans le template.
* **Conception** : calculer le tableau à partir du signal du formulaire pour maintenir la réactivité.
* @example
* const addresses = conferenceForm.addressesArray();
*/
readonly addressesArray = computed(() => {
return this.form().get('addresses') as FormArray;
});
/**
* **Objectif** : exposer le FormArray des sessions pour la liaison dans le template.
* **Conception** : calculer le tableau à partir du signal du formulaire pour maintenir la réactivité.
* @example
* const sessions = conferenceForm.sessionsArray();
*/
readonly sessionsArray = computed(() => {
return this.form().get('sessions') as FormArray;
});
/**
* **Objectif** : créer un FormGroup réutilisable pour une adresse.
* **Conception** : encapsuler la structure pour éviter la duplication lors de l'ajout d'adresses.
* @example
* const addressGroup = conferenceForm.createAddressGroup();
*/
private createAddressGroup(): FormGroup {
return this.formBuilder.group({
street: ['', Validators.required],
city: ['', Validators.required],
postalCode: ['', Validators.required]
});
}
/**
* **Objectif** : créer un FormGroup réutilisable pour une session.
* **Conception** : encapsuler la structure pour éviter la duplication lors de l'ajout de sessions.
* @example
* const sessionGroup = conferenceForm.createSessionGroup();
*/
private createSessionGroup(): FormGroup {
return this.formBuilder.group({
title: ['', Validators.required],
duration: [0, [Validators.required, Validators.min(1)]]
});
}
/**
* **Objectif** : ajouter une nouvelle adresse au FormArray.
* **Conception** : pousser un nouveau FormGroup pour maintenir le tableau réactif et déclencher la détection des changements.
* @example
* conferenceForm.addAddress();
*/
addAddress(): void {
this.addressesArray().push(this.createAddressGroup());
}
/**
* **Objectif** : retirer une adresse du FormArray par index.
* **Conception** : utiliser removeAt pour maintenir l'intégrité du tableau et éviter les soumissions vides.
* @example
* conferenceForm.removeAddress(0);
*/
removeAddress(index: number): void {
const addresses = this.addressesArray();
if (addresses.length > 1) {
addresses.removeAt(index);
}
}
/**
* **Objectif** : ajouter une nouvelle session au FormArray.
* **Conception** : pousser un nouveau FormGroup pour maintenir le tableau réactif et déclencher la détection des changements.
* @example
* conferenceForm.addSession();
*/
addSession(): void {
this.sessionsArray().push(this.createSessionGroup());
}
/**
* **Objectif** : retirer une session du FormArray par index.
* **Conception** : utiliser removeAt pour maintenir l'intégrité du tableau et éviter les soumissions vides.
* @example
* conferenceForm.removeSession(0);
*/
removeSession(index: number): void {
const sessions = this.sessionsArray();
if (sessions.length > 1) {
sessions.removeAt(index);
}
}
/**
* **Objectif** : soumettre le formulaire et sérialiser le payload pour affichage.
* **Conception** : valider le formulaire, marquer tous les champs comme touchés si invalide, et stocker le résultat.
* @example
* conferenceForm.submit();
*/
submit(): void {
const currentForm = this.form();
if (currentForm.invalid) {
currentForm.markAllAsTouched();
return;
}
this.lastSubmission.set(JSON.stringify(currentForm.value, null, 2));
}
}Validez chaque niveau
Ajoutez des validateurs au niveau du FormGroup parent pour vérifier la cohérence globale (ex: au moins une adresse, au moins une session).
Évitez les références directes
Utilisez des computed() pour exposer les FormArray plutôt que de stocker des références directes qui peuvent devenir obsolètes après une mise à jour du formulaire.
Accéder aux valeurs et valider à chaque niveau
Réflexion
L'imbrication permet d'accéder et de valider chaque niveau indépendamment. Voici comment naviguer dans la hiérarchie :
Accéder aux valeurs
ts
// Niveau racine : FormControl direct
const firstName = this.form().get('firstName')?.value;
// Niveau 1 : FormGroup imbriqué (méthode chaînée)
const street = this.form().get('address')?.get('street')?.value;
// Niveau 1 : FormGroup imbriqué (notation par points - plus court)
const city = this.form().get('address.city')?.value;
// Niveau 1 : FormArray, élément [0], FormControl
const firstAddressStreet = this.form().get('addresses')?.get('0')?.get('street')?.value;
// Niveau 1 : FormArray (notation par points)
const firstAddressCity = this.form().get('addresses.0.city')?.value;
// Niveau 1 : FormArray, élément [1] (si existe)
const secondAddressStreet = this.form().get('addresses.1.street')?.value;Valider chaque niveau
ts
// Valider le formulaire entier
const isFormValid = this.form().valid;
// Valider un FormGroup imbriqué spécifique
const isAddressValid = (this.form().get('address') as FormGroup)?.valid;
// Valider un FormArray
const areAddressesValid = (this.form().get('addresses') as FormArray)?.valid;
// Valider un élément spécifique du FormArray
const isFirstAddressValid = (this.form().get('addresses.0') as FormGroup)?.valid;
// Valider un FormControl spécifique dans un FormGroup imbriqué
const isStreetValid = this.form().get('address.street')?.valid;
// Valider un FormControl dans un FormArray
const isFirstStreetValid = this.form().get('addresses.0.street')?.valid;Marquer les niveaux comme touchés
ts
// Marquer tout le formulaire
this.form().markAllAsTouched();
// Marquer uniquement un FormGroup imbriqué
(this.form().get('address') as FormGroup)?.markAllAsTouched();
// Marquer tous les éléments d'un FormArray
const addresses = this.form().get('addresses') as FormArray;
addresses.controls.forEach((control) => {
(control as FormGroup).markAllAsTouched();
});
// Marquer un élément spécifique du FormArray
(this.form().get('addresses.0') as FormGroup)?.markAllAsTouched();Exemple pratique : valider avant soumission
ts
submit(): void {
const form = this.form();
// Valider le niveau racine
if (form.invalid) {
form.markAllAsTouched();
return;
}
// Valider chaque adresse individuellement
const addresses = form.get('addresses') as FormArray;
addresses.controls.forEach((addressGroup, index) => {
if ((addressGroup as FormGroup).invalid) {
console.log(`Address ${index} is invalid`);
(addressGroup as FormGroup).markAllAsTouched();
}
});
// Valider les sessions
const sessions = form.get('sessions') as FormArray;
if (sessions.length === 0) {
console.log('At least one session is required');
return;
}
// Soumettre si tout est valide
console.log('Form submitted:', form.value);
}