Are you building a nested form? Need each form step to live on its own route? If that sounds familiar, this guide is for you. Meet the ControlContainer!
This article draws from Jennifer Wadell's ngConf 2020 talk, which we helped sponsor. The talk hasn't been uploaded to the official ng-Conf YouTube channel yet — as soon as it is, we'll link it here.
Understanding ControlContainer
According to the official docs, "ControlContainer is a base class for directives that contain multiple registered instances of NgControl". The FormGroup directive is one such example. A peek at its source code reveals that it registers 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'
})
Once that directive is attached to any DOM element, you can inject ControlContainer (here, an instance of FormGroupDirective) both within that element and any of its child components. This works because of how ElementInjector resolves dependencies.
Putting It Into Practice
Let's walk through a two-step order form that relies on ControlContainer:
- collecting the shipping address
- gathering credit card details for payment
Both steps live on separate routes.
The parent component comes first, and that's where the form instance lives.
@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 creates the form, which is then wired to the [formGroup] directive placed on the <form> element in the view. Inside <form> — where you see <router-outlet> — the correct step renders based on the current route.
Next, the child component (representing one step) injects ControlContainer. Through it, the [formGroup] directive from the parent becomes accessible. The rest is straightforward: from the injected FormGroupDirective, you grab the form control you need and bind it to the 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 how you prefer to structure and reuse components,
ControlContainergives you two options:
- pick the exact form control at the child component level:
this.form = this.controlContainer.control.get('creditCard') as FormGroup;This approach does require you to use the same field name for that control throughout the entire application.
- pass it straight to the child's input, which is probably where it'll be used. Here, the parent gets to decide which form control the child sees:
<form [formGroup]="form[selectedStep]"> < router-outlet></router-outlet> </> formWhere
selectedStep, in this case,creditCardoraddressdepending on the active route.
And that's it — multi-step forms on different routes, done cleanly.
Full working code is here: https://stackblitz.com/edit/angular-love-ccZ
You might also enjoy Netanel Basal's take on an interesting ControlContainer use case.
