Two Strategies for Building Reusable Reactive Forms in Angular
This guide covers two distinct techniques for implementing reactive forms that work both as embedded sub-forms and as self-contained forms in their own right.
The assumption here is that you already have a working familiarity with Angular reactive forms.
Here are the two approaches we will explore:
- The ControlContainer technique, which lets a parent form feed its own instance down to child form components.
- The @ViewChild technique, which gives the parent a direct reference to the child form component's class instance.
Angular offers both reactive and template-driven methods to build forms; the examples here use the reactive style.
What We Are Building
A short video demonstrates the final form's interface for this guide.
In the video, you can see a single large form that is composed of two nested sub-forms:
- HeroComponent, the top-level parent form
- PowersComponent, a child accessed through the
@ViewChilddecorator - HobbiesComponent, a child wired up with the
ControlContainerclass
Creating a Sub-Form with the @ViewChild Decorator
We start with the cleaner method, which leverages the @ViewChild decorator so the parent HeroComponent can access the component class of its child, PowersComponent. The implementation looks like this:
HeroComponent – Parent form owner
// hero.component.html
<form [formGroup]="heroForm">
<nb-card>
<nb-card-header>Hero</nb-card-header>
<nb-card-body class="col">
<input
formControlName="heroName"
type="text"
nbInput
placeholder="Hero name"
/>
<input formControlName="aka" type="text" nbInput placeholder="AKA" />
</nb-card-body>
</nb-card>
<nb-card>
<nb-card-header>Super Power</nb-card-header>
<nb-card-body class="col">
<app-powers></app-powers>
</nb-card-body>
</nb-card>
<nb-card>
<nb-card-header>Hobbies</nb-card-header>
<nb-card-body class="col">
<app-hobbies
[parentForm]="heroForm"
[formGroup]="heroForm.get('hobbies')"
></app-hobbies>
</nb-card-body>
</nb-card>
<button (click)="logFormData()" nbButton status="primary">Submit</button>
</form>
// hero.component.ts
import { Component, OnInit, ViewChild } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { PowersComponent } from '../powers/powers.component';
@Component({
selector: 'app-hero',
templateUrl: './hero.component.html',
styleUrls: ['./hero.component.scss']
})
export class HeroComponent implements OnInit {
@ViewChild(PowersComponent, { static: true }) public powersComponent: PowersComponent;
public heroForm: FormGroup;
constructor(private formBuilder: FormBuilder) {
}
public ngOnInit(): void {
this.heroForm = this.formBuilder.group({
heroName: ['', Validators.required],
aka: ['', Validators.required],
powers: this.powersComponent.createFormGroup(),
hobbies: this.formBuilder.group({
favoriteHobby: ['', Validators.required]
})
})
}
public logFormData(): void {
console.log(this.heroForm.value);
}
}
Notice in the HeroComponent template that the PowersComponent does not require any special input bindings.
<nb-card>
<nb-card-header>Super Power</nb-card-header>
<nb-card-body class="col">
<app-powers></app-powers> // here
</nb-card-body>
</nb-card>
Inside the HeroComponent class, however, you will see how we fetch the PowersComponent instance and invoke its public createFormGroup method to retrieve its FormGroup configuration.
@ViewChild(PowersComponent, { static: true }) public powersComponent: PowersComponent;
Pay attention to the @ViewChild settings, which use static: true. This ensures the child instance is resolved as early as possible so the parent has access to the sub-form right away.
Line 21 in the HeroComponent.ts file is where the PowersComponent form gets instantiated.
powers: this.powersComponent.createFormGroup(),
That covers the @ViewChild decorator technique. I prefer this method because it is direct, less prone to confusion, and keeps the parent and child forms logically separate, offering these advantages:
- The parent form has zero knowledge of the sub-form's internals; it just expects the child component class to expose a public createFormGroup method that gives back a FormGroup instance.
- Refactoring the sub-form's internal structure has no ripple effect on the parent's configuration or markup.
Now, what about writing unit tests for this pattern?
That is equally simple, as shown below.
Unit testing the @ViewChild approach
// hero.component.spec.ts
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HobbiesStubComponent } from './component-stubs/hobbies-stub.component';
import { HeroComponent } from './hero.component';
describe('HeroComponent', () => {
let component: HeroComponent;
let fixture: ComponentFixture<HeroComponent>;
const formBuilder: FormBuilder = new FormBuilder();
const powersComponent = jasmine.createSpyObj('PowersComponent', ['createFormGroup']);
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [HeroComponent, HobbiesStubComponent],
providers: [{ provide: FormBuilder, useValue: formBuilder }],
imports: [FormsModule, ReactiveFormsModule]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HeroComponent);
component = fixture.componentInstance;
component.powersComponent = powersComponent;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
In lines 11 and 25 of that code, you can see a fake PowersComponent being created. Its createFormGroup function is spoofed using Jasmine's createSpyObj utility.
const powersComponent = jasmine.createSpyObj('PowersComponent'['createFormGroup']; // line 11
...
component.powersComponent = powersComponent; // line 25
Omitting this stub in the HeroComponent spec file will cause test failures with the following message:
TypeError: Cannot read property 'createFormGroup' of undefined
Creating a Sub-Form with the ControlContainer
This second method is more involved and can be a bit perplexing, though it is still a valid option.
The HeroComponent template includes markup for its other child form, the HobbiesComponent.
<app-hobbies [parentForm]="heroForm [formGroup]="heroForm.get('hobbies')"
></app-hobbies>
This markup relies on the [formGroup] directive, a feature of the ControlContainer, which lets the parent form provide its own instance to the child form if that is required.
Further, the child form accepts an input that is the parent form, which gives the child access to the parent when necessary — you can see the source for this sub-form below.
HobbiesComponent – Built with ControlContainer
// hobbies.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { ControlContainer, FormGroup } from '@angular/forms';
@Component({
selector: 'app-hobbies',
templateUrl: './hobbies.component.html',
styleUrls: ['./hobbies.component.scss']
})
export class HobbiesComponent implements OnInit {
public hobbiesForm: FormGroup;
@Input() parentForm: FormGroup;
constructor(private controlContainer: ControlContainer) { }
public ngOnInit(): void {
this.hobbiesForm = this.controlContainer.control as FormGroup;
}
public logForms(): void {
console.log('Hobbies form', this.hobbiesForm);
console.log('Parent (Hero) form', this.parentForm);
}
}
Unit Testing This Setup
First, set up a simple stub for HobbiesComponent and include it in the parent form's TestBed declarations.
Component stub
// hobbies-stub.component.ts
import { Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'app-hobbies',
template: ''
})
export class HobbiesStubComponent {
}
That stub is all the parent needs to successfully test a sub-form driven by ControlContainer.
From there, the HobbiesComponent gets its own spec file.
HobbiesComponent tests
// hobbies.component.spec.ts
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ControlContainer, FormBuilder, Validators } from '@angular/forms';
import { HobbiesComponent } from './hobbies.component';
describe('HobbiesComponent', () => {
let component: HobbiesComponent;
let fixture: ComponentFixture<HobbiesComponent>;
const formBuidler: FormBuilder = new FormBuilder();
const hobbyForm = formBuidler.group({
favoriteHobby: ['', Validators.required]
})
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [HobbiesComponent],
providers: [{ provide: ControlContainer, useValue: hobbyForm }],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HobbiesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
In that spec, you will see a dummy FormGroup created with a mock FormBuilder (line 9). This dummy group is then provided as the token for ControlContainer on line 18.
There is no obvious benefit to this second strategy, though it functions. It does introduce several maintenance-related downsides.
- A change in the sub-form, like renaming a property, forces the parent form to update its configuration to match.
- If the parent form changes how it configures the sub-form, the child must be updated — for example, when the formControlName value needs adjusting.
- Every sub-form component needs a stub in the tests to prevent warnings.
- Any new parent that wants to reuse this sub-form must supply its own configuration for it.
hobbies: this.formBuilder.group({
favoriteHobby: ['', Validators.required]
})
Final Thoughts
Between the two options, the @ViewChild decorator approach stands out as the superior choice because it offers full encapsulation, easy reuse, and simpler upkeep.
The alternative using ControlContainer can work, but it clearly brings extra friction and tight coupling.
Ultimately, the decision is yours, as it is your code and your reasoning.
I hope this walkthrough was helpful and informative.
The complete source code is available on GitHub.
