Son astuce pour mieux se former

Plonge dans une interview inspirante et condensée de Gérôme Grignon, développeur frontend passionné et figure incontournable de la communauté Angular francophone.

Dans cet échange, Gérôme partage son parcours, ses conseils d'apprentissage, sa vision d'Angular et sa réflexion sur l'usage de l'IA dans le développement web.

Skip to content

Vous souhaitez recevoir de l'aide sur ce sujet ? rejoignez la communauté Angular.fr sur Discord.

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 :

  1. Un FormGroup racine pour l'utilisateur.
  2. Des FormGroup imbriqués pour les adresses.
  3. Un FormArray pour 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: city

Dans 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 racine
  • formControlName="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)

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);
}

Exemple complet