~9 min de lecture

NG01053 : ton formulaire explose dès que tu l'extrais en sous-composant (le fix tient en une ligne)

Ton composant de checkout fait 400 lignes. Adresse de livraison, adresse de facturation, paiement, tout dans le même template, tout dans le même FormGroup. Tu fais ce que tout développeur ferait : tu extrais la section adresse dans un sous-composant.

Et là, une erreur que tu n'avais jamais vue :

RuntimeError: NG01053: formGroupName must be used with a parent formGroup directive.  You'll want to add a formGroup
    directive and pass it an existing FormGroup instance (you can create one in your class).
    ...

Le formGroup parent existe pourtant. Il est juste là, dans le template du parent, à un composant de distance. Ton code compilait, le refactoring était purement mécanique, et pourtant le runtime te dit que ton formGroupName est orphelin.

Ce n'est pas un bug, c'est une frontière de DI. Et le fix tient en une ligne de viewProviders, à condition de comprendre ce qu'elle fait.


TL;DR

Approche Verdict
formGroupName dans le sous-composant, sans rien d'autre NG01053 au runtime (en dev)
Passer le FormGroup en input() Fonctionne, mais verbeux et répétitif
Un ControlValueAccessor par section Usine à gaz, validité à recâbler à la main
viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }] Une ligne, tout le reste du code est identique à l'avant-découpage

Règle mnémotechnique pour la ligne gagnante : ControlContainer est la classe abstraite que formGroupName et formControlName vont chercher en DI pour retrouver le formulaire qui les héberge, host borne cette recherche à la frontière du composant, viewProviders fournit un service à la vue du composant, useExisting référence une instance déjà créée au lieu d'en construire une.

Le pattern marche tel quel d'Angular 17.0 à Angular 22.1 : ni les flags DI de formGroupName ni le provider de [formGroup] n'ont bougé sur cette plage. Les exemples utilisent la syntaxe actuelle (input() signal, apparu en 17.1 et stable depuis Angular 19 ; en 17.0, @Input({ required: true }) fait le même travail).


La situation de départ

Un checkout classique, en Typed Forms :

// checkout.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-checkout',
  imports: [ReactiveFormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <fieldset formGroupName="shipping">
        <input formControlName="street" placeholder="Street" />
        <input formControlName="zipCode" placeholder="Zip code" />
        <input formControlName="city" placeholder="City" />
      </fieldset>

      <!-- ... billing, payment, 300 lignes de plus ... -->

      <button type="submit" [disabled]="form.invalid">Order</button>
    </form>
  `,
})
export class Checkout {
  private readonly fb = inject(NonNullableFormBuilder);

  protected readonly form = this.fb.group({
    shipping: this.fb.group({
      street: ['', Validators.required],
      zipCode: ['', Validators.required],
      city: ['', Validators.required],
    }),
    // billing, payment...
  });

  protected submit(): void {
    console.log(this.form.getRawValue());
  }
}

Tant que tout vit dans un seul template, formGroupName="shipping" trouve son [formGroup] parent et tout roule. Le problème n'est pas le code, c'est sa taille : ce composant est illisible et personne ne veut le toucher.

Le refactoring naturel, et pourquoi il casse

Tu déplaces le fieldset tel quel dans un sous-composant :

// shipping-address.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-shipping-address',
  imports: [ReactiveFormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <fieldset formGroupName="shipping">
      <input formControlName="street" placeholder="Street" />
      <input formControlName="zipCode" placeholder="Zip code" />
      <input formControlName="city" placeholder="City" />
    </fieldset>
  `,
})
export class ShippingAddress {}

Et le parent devient propre :

<form [formGroup]="form" (ngSubmit)="submit()">
  <app-shipping-address />
  <button type="submit" [disabled]="form.invalid">Order</button>
</form>

Compilation : OK. Runtime : NG01053. Le markup du fieldset n'a pas bougé d'un caractère, seule la frontière de composant s'est déplacée. C'est donc elle, la coupable.

Précision qui compte : cette erreur explicite est jetée en mode dev, gardée par ngDevMode. En build de production, la garde saute et tu récupères à la place un TypeError sur addFormGroup (dans Chrome : Cannot read properties of null (reading 'addFormGroup')), nettement plus opaque. Raison de plus pour attraper le problème avant le build.

Ce que formGroupName cherche vraiment

formGroupName, formControlName et formArrayName ne parlent pas directement au FormGroup. Ils injectent un ControlContainer, la classe abstraite dont héritent toutes les directives conteneurs de formulaire. C'est [formGroup] (la directive FormGroupDirective) qui se fournit lui-même comme ControlContainer sur son élément, via un useExisting - la recette de provider qui ne construit rien et référence une instance déjà là.

Le détail qui change tout est la façon dont formGroupName fait cette injection. Dans le code source d'Angular, sa dépendance est déclarée avec trois flags :

{ token: ControlContainer, optional: true, host: true, skipSelf: true }

skipSelf ignore son propre élément. optional évite l'erreur DI générique : la directive vérifie elle-même et jette la NG01053 explicite. Et surtout host arrête la recherche à la frontière du composant hôte.

Tant que le fieldset vivait dans le template de Checkout, la recherche remontait les éléments du template et trouvait le ControlContainer fourni par [formGroup] sur le <form>. Une fois le fieldset déplacé dans ShippingAddress, la recherche part du fieldset, atteint la frontière du composant... et s'arrête. Le <form> du parent est hors de portée par construction. D'où l'erreur, alors que le FormGroup existe bel et bien un étage au-dessus.

C'est le même mécanisme de résolution hiérarchique que pour n'importe quel service, borné par le flag host. Rien de spécifique aux formulaires : les formulaires sont juste l'endroit où tu te le prends en pleine figure. Les quatre flags de résolution (optional, self, skipSelf, host) ont leur guide dédié.

Les deux contournements que tu as déjà vus (et leurs vrais coûts)

Passer le groupe en input(). Le parent envoie form.controls.shipping, l'enfant le rebinde avec [formGroup] sur son propre élément racine. Ça marche, la validité remonte (elle transite par le modèle FormGroup, pas par les directives). Mais chaque sous-composant doit déclarer un input typé sur la structure exacte du groupe et poser son propre wrapper [formGroup]. Et le parent trimballe des form.controls.xxx partout dans son template. Pour trois sections, c'est du bruit. Pour dix, c'est un pattern d'architecture à part entière que tu subis.

Un ControlValueAccessor par section. L'artillerie lourde : l'enfant devient un contrôle custom qui expose la valeur du bloc entier. Sauf qu'un CVA ne propage pas la validité de son formulaire interne : tant que tu n'exposes pas la validité toi-même - la voie idiomatique est NG_VALIDATORS, le token par lequel un contrôle expose ses validateurs synchrones - ton form.invalid parent ment. Le CVA est le bon outil pour un widget de saisie réutilisable (détaillé dans notre guide du ControlValueAccessor), pas pour découper un formulaire en sections.

Le fix : redonner un ControlContainer visible depuis la vue

La solution tient dans le sous-composant, en une ligne :

// shipping-address.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ControlContainer, FormGroupDirective, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-shipping-address',
  imports: [ReactiveFormsModule],
  viewProviders: [
    { provide: ControlContainer, useExisting: FormGroupDirective },
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <fieldset formGroupName="shipping">
      <input formControlName="street" placeholder="Street" />
      <input formControlName="zipCode" placeholder="Zip code" />
      <input formControlName="city" placeholder="City" />
    </fieldset>
  `,
})
export class ShippingAddress {}

Ni le template de l'enfant, ni celui du parent, ni le FormGroup ne changent. Décortiquons pourquoi ça suffit.

  1. Le formGroupName du template cherche un ControlContainer avec host: true. Sa recherche s'arrête à la frontière de ShippingAddress, mais elle inclut les viewProviders du composant hôte : c'est exactement à ça qu'ils servent, fournir quelque chose à la vue du composant.
  2. Il y trouve le provider, qui dit : le ControlContainer, c'est le FormGroupDirective existant.
  3. Résoudre FormGroupDirective depuis l'injecteur de ShippingAddress déclenche une recherche hiérarchique classique, sans flag host cette fois. Elle remonte librement jusqu'au <form [formGroup]> du parent et récupère la directive déjà instanciée.

En clair : la ligne viewProviders fait le pont au-dessus de la frontière que host interdit de franchir. Le formGroupName retrouve exactement le même objet que quand tout vivait dans un seul template.

Pourquoi viewProviders et pas providers ? Pas par élégance : providers ne répare rien. Une recherche bornée par host ne lit, sur l'élément-frontière, que les viewProviders du composant hôte - ses providers lui restent invisibles, et la même ligne déplacée dans providers laisse la NG01053 en place. La différence exacte entre les deux tableaux est traitée dans providers vs viewProviders : le service qui disparaît quand tu projettes ton contenu.

Corollaire à garder en tête : si un jour ta section projette des champs via ng-content, ce contenu projeté ne verra pas tes viewProviders. Et le symptôme est plus sournois que le NG0201 de l'article lié juste avant : pas d'erreur DI, le champ projeté se raccroche en silence au [formGroup] racine du parent et échoue plus loin, sur un Cannot find control with name.

Version réutilisable : le nom du groupe en input()

Coder "shipping" en dur dans l'enfant limite la réutilisation. Une adresse de livraison et une adresse de facturation, c'est le même bloc de champs sous deux clés différentes :

// address-form.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { ControlContainer, FormGroupDirective, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-address-form',
  imports: [ReactiveFormsModule],
  viewProviders: [
    { provide: ControlContainer, useExisting: FormGroupDirective },
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <fieldset [formGroupName]="groupName()">
      <input formControlName="street" placeholder="Street" />
      <input formControlName="zipCode" placeholder="Zip code" />
      <input formControlName="city" placeholder="City" />
    </fieldset>
  `,
})
export class AddressForm {
  readonly groupName = input.required<string>();
}
<form [formGroup]="form" (ngSubmit)="submit()">
  <app-address-form groupName="shipping" />
  <app-address-form groupName="billing" />
  <button type="submit" [disabled]="form.invalid">Order</button>
</form>

Deux instances, deux sous-groupes, zéro duplication de template. La validité, le touched, le dirty : tout continue de remonter naturellement dans le form parent, puisque c'est le même arbre de contrôles qu'avant le découpage.

Qui possède la structure du groupe ?

Il reste un couplage silencieux : l'enfant écrit formControlName="street", donc il exige que le parent construise un groupe avec exactement ces clés. Si la construction du groupe reste dans le parent, la structure vit à deux endroits et une typo ne se voit qu'au runtime (formControlName et formGroupName matchent par chaîne de caractères, le compilateur ne vérifie rien).

Le correctif est organisationnel : la factory du groupe vit dans le fichier du sous-composant, le parent ne fait que composer.

// address-form.ts (même fichier que le composant)
import { NonNullableFormBuilder, Validators } from '@angular/forms';

export function buildAddressGroup(fb: NonNullableFormBuilder) {
  return fb.group({
    street: ['', Validators.required],
    zipCode: ['', Validators.required],
    city: ['', Validators.required],
  });
}
// checkout.ts
protected readonly form = this.fb.group({
  shipping: buildAddressGroup(this.fb),
  billing: buildAddressGroup(this.fb),
});

Une seule source de vérité pour la structure : le fichier qui contient aussi les formControlName. Ajouter un champ se fait à un seul endroit, et le parent garde un FormGroup entièrement typé : la factory retourne un groupe typé, donc form.getRawValue() conserve le type précis de chaque champ (les subtilités value vs getRawValue() sont traitées à part).

Et en template-driven ?

Même frontière, même remède. Un ngModelGroup dans un sous-composant cherche lui aussi un ControlContainer borné par host, fourni cette fois par la directive NgForm du <form> parent. La ligne devient :

viewProviders: [{ provide: ControlContainer, useExisting: NgForm }],

Le reste se transpose tel quel : FormsModule dans les imports du sous-composant à la place de ReactiveFormsModule, ngModelGroup et ngModel dans son template, et le nom du groupe en input() si tu veux réutiliser la section plusieurs fois. La valeur et la validité remontent dans le NgForm parent comme avant le découpage. Seule différence de symptôme : sans le fix, ngModelGroup (qui injecte sans optional) ne jette pas une erreur de forms mais un NG0201: No provider for ControlContainer.

Les limites à connaître avant de généraliser

  • Le sous-composant ne vit plus seul. Il exige au-dessus de lui la directive exacte que désigne ton useExisting - ici un [formGroup] : monte-le en dehors d'un formulaire et tu retombes sur la même NG01053, le useExisting renvoyant null faute de FormGroupDirective à référencer. C'est un composant de section, pas un widget autonome. Si tu veux un vrai contrôle indépendant, c'est le territoire du CVA.
  • Le pattern ne s'imbrique pas tel quel. useExisting: FormGroupDirective désigne la directive [formGroup] racine : une section qui en contient une autre saute le formGroupName intermédiaire, et tu récoltes au mieux un Cannot find control with name, au pire un câblage silencieusement faux. Pour imbriquer, relaie plutôt le conteneur immédiat, dans les mêmes viewProviders : { provide: ControlContainer, useFactory: () => inject(ControlContainer, { skipSelf: true }) }.
  • Le contrat reste une affaire de chaînes. groupName="shipping" doit matcher une clé du FormGroup parent. La factory réduit le risque côté champs, mais la clé du groupe reste une chaîne vérifiée au runtime seulement.
  • Dans tes tests, monte un vrai hôte. Un TestBed qui crée AddressForm isolément n'a pas de FormGroupDirective à offrir au useExisting. Le plus simple est un composant hôte de test qui pose le <form [formGroup]> et insère la section, comme en production.

Récap actionnable

  • formGroupName et formControlName injectent ControlContainer avec host: true : la recherche s'arrête à la frontière du composant, d'où la NG01053 dès que tu extrais une section.
  • Le fix : viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }] dans le sous-composant. Une ligne, aucun autre changement.
  • viewProviders et pas providers : une recherche bornée par host ne voit que les viewProviders de l'hôte, la même ligne en providers ne répare rien.
  • Rends la section réutilisable avec groupName = input.required<string>() et [formGroupName]="groupName()".
  • Mets la factory du groupe dans le fichier du sous-composant : une seule source de vérité pour les clés.
  • Garde le CVA pour les widgets de saisie autonomes, pas pour découper un formulaire.

La prochaine fois qu'un composant de formulaire dépasse les 200 lignes, tu n'as plus d'excuse : la frontière qui t'empêchait de le découper se franchit en une ligne de viewProviders, et tout ce que tu sais sur formGroupName reste vrai de l'autre côté.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.