05 Jun 2020
3 min

Nested forms with ControlContainer

Are you creating a nested form? Do you want individual form steps to appear on different routing paths? If so, then this post will come in handy. Experience the power of the ControlContainer!

The article is based on Jennifer Wadell's speech at ngConf in 2020, of which we were a partner. Unfortunately, the speech has not yet been made available on the official ng-Conf YouTube channel. As soon as it appears, it will be attached to this article.

ControlContainer

As we can read in the documentation –"ControlContainer is a base class for directives that contain multiple registered instances of NgControl". For example, FormGroup, is a type of directive. Looking at the source code, we will notice that it provides itself as a ControlContainer.

export const  formDirectiveProvider:  any  =  {
provide:  ControlContainer,
useExisting:  forwardRef(()  =>  FormGroupDirective)
};
 
@Directive({
selector:  '[formGroup]',
 providers: [formDirectiveProvider],
host: {'(submit)':  'onSubmit($event)',  '(reset)':  'onReset()'},
exportAs:  'ngForm'
})

After applying the previously mentioned directive to any DOM element, we will be able to inject the ControlContainer (in this case being an instance of FormGroupDirective) inside this element and its child component. This is all the result of the resolution of dependencies by  ElementInjector.

Implementation

The use of ControlContainer will be presented in the example of a form for order placement. It consists of two steps:

  1. providing the shipping address
  2. providing credit card information for payment

Each step is on a different routing path.

Implementation starts with the parent component, inside which the instance of our form will be kept.

@Component({
 selector: 'app-root',
 template: `
   <div class="container">
     <form [formGroup]="form">
       <router-outlet></router-outlet>
     </form>
     <button routerLink="address" type="button" mat-button>Step 1</button>
     <button routerLink="credit-card" type="button" mat-button>Step 2</button>
   </div>
 `,
 styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
 title = 'control-container';
 form: FormGroup;
 
 constructor(
   private fb: FormBuilder,
 ) {
 }
 
 ngOnInit(): void {
   this.initForm();
 }
 
 initForm(): void {
   this.form = this.fb.group({
     address: this.fb.group({
       city: [''],
       street: [''],
       homeNumber: ['']
     }),
     creditCard: this.fb.group({
       cardNumber: [''],
       ccvNumber: [''],
       expirationDate: ['']
     })
   });
 }
}

This initiates the form, which will then be passed to the [formGroup] directive superimposed on the form element in the view. Inside <form> in place of <router-outlet>  the appropriate step of the form will be displayed depending on the path on which we are located.

Then, to the child component, which is a representative of one of the steps, the ControlContainer will be injected, through which we can access the [formGroup] directive from the parent. The rest is only a formality. From the obtained instance of  FormGroupDirective,  we download the form field we are interested in and associate it with our view.

@Component({
 selector: 'app-credit-card-form',
 template: `
   <div [formGroup]="form" class="container">
     <mat-form-field>
       <mat-label>Card number</mat-label>
       <input matInput placeholder="Card number" formControlName="cardNumber" required>
     </mat-form-field>
     <mat-form-field>
       <mat-label>CCV number</mat-label>
       <input matInput placeholder="CCV number" formControlName="ccvNumber" required>
     </mat-form-field>
     <mat-form-field>
       <mat-label>Expiration date</mat-label>
       <input matInput placeholder="Expiration date" formControlName="expirationDate" required>
     </mat-form-field>
   </div>
 `,
 styleUrls: ['./credit-card-form.component.css']
})
export class CreditCardFormComponent implements OnInit {
 form: FormGroup;
 
constructor(private controlContainer: ControlContainer) {
 }
 
ngOnInit(): void {
   this.form = this.controlContainer.control.get('creditCard') as FormGroup;
 }
}

Depending on your preferred way of implementing and reusing components, you can use ControlContainer in two ways:

  • by selecting the form field you are interested in, on the child component level:
this.form = this.controlContainer.control.get('creditCard') as FormGroup;

However, this solution obliges you to name the downloaded field in the child component the same throughout the application.

  • transmit directly to the child’s field, in which it will be used. Here, from the parent level, we control which form field our child will access:
<form  [formGroup]="form[selectedStep]">
< router-outlet></router-outlet>
</> form

Where selectedStep, in this case, creditCard or address depending on the path on which we are located.

In this simple way, we have implemented multi-step forms, on different routes.

Source code for a complete solution: https://stackblitz.com/edit/angular-love-ccZ

I also encourage you to familiarize yourself with the interesting use of ControlContainer, which has been presented by Netanel Basal on his blog.

Frequently asked questions

How do I split a multi-step form across different routes in Angular?

Put a [formGroup] directive on a <form> in the parent component and place <router-outlet> inside it, so each routed step renders within the same FormGroup. Each step then reads its part of the form through an injected ControlContainer, with the parent form holding nested groups such as address and creditCard. This keeps one form instance alive while the user navigates between routes like address and credit-card.

How do I inject ControlContainer to access a parent FormGroup in a child component?

Inject ControlContainer from @angular/forms in the child component's constructor; it resolves to the FormGroupDirective of the nearest ancestor element that has [formGroup]. Then read the nested control with this.controlContainer.control.get('creditCard') as FormGroup. Because the lookup is performed by the ElementInjector, the child must be rendered inside that form element, which is why <router-outlet> is placed within it.

When should I pass the form group from the parent instead of getting it via ControlContainer in a child component?

Selecting the group with this.controlContainer.control.get('creditCard') in the child is the simplest approach, but it forces every child to use the same control name across the application. Alternatively, bind [formGroup]="form[selectedStep]" on the parent form so the parent decides which group the routed child uses. This keeps child components reusable, since they no longer depend on a hardcoded control name.

How does ControlContainer get resolved to FormGroupDirective in Angular?

FormGroupDirective provides itself as ControlContainer through the formDirectiveProvider, which sets provide: ControlContainer and useExisting: forwardRef(() => FormGroupDirective). As a result, any component inside an element with [formGroup] can inject ControlContainer and receive the same directive instance. Angular's ElementInjector performs this resolution, following the DOM hierarchy of the template.

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.