Angular Reactive Forms at Scale: Typed Controls, Dynamic Fields, and Cross-Field Validation
A 60-field tax-filing form taught me more about Angular forms than any documentation. What survived the project.
The US tax-filing platform I worked on had a form with sixty-plus fields, a dozen conditional sections, and validation rules that depended on other fields' values. It was the hardest UI problem on the project — harder than the payments integration. Here is what I'd do again and what I'd never repeat.
Type your forms, and let the compiler earn its keep
Typed reactive forms turn a whole class of runtime bug into a compile error. form.get('filerName') returning AbstractControl | null is how you end up with ?.value sprinkled everywhere and a production error when someone renames a control.
interface FilerForm {
name: FormControl<string>;
ssn: FormControl<string>;
filingStatus: FormControl<'single' | 'joint' | 'separate'>;
spouse: FormGroup<SpouseForm> | null;
}
private fb = inject(NonNullableFormBuilder);
form = this.fb.group<FilerForm>({
name: this.fb.control('', Validators.required),
ssn: this.fb.control('', [Validators.required, ssnValidator]),
filingStatus: this.fb.control<'single' | 'joint' | 'separate'>('single'),
spouse: null,
});NonNullableFormBuilder is the detail worth calling out. By default a reset sets controls to null, which is why every value type is T | null. Using the non-nullable builder means a reset returns the initial value instead, and your types stop being littered with nulls that can't actually occur in your flow.
Dynamic sections: add and remove controls, don't hide them
The first version of the spouse section used *ngIf on the template and left the controls in the form. That meant hidden required fields blocking submission, and the user staring at a disabled button with no visible error. Every conditional form has this bug at some point.
constructor() {
this.form.controls.filingStatus.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((status) => {
if (status === 'joint') {
this.form.setControl('spouse', buildSpouseGroup(this.fb));
} else {
this.form.removeControl('spouse');
}
});
}If a field isn't on screen, it isn't in the form. The form's validity then always matches what the user can see, and "why is submit disabled" stops being a support ticket.
Cross-field validation belongs on the group
A rule involving two fields cannot live on either one of them. Put it on their common parent, where both values are available:
const dependantAgeRule: ValidatorFn = (group) => {
const { birthDate, claimedAsDependant } = group.value;
if (!birthDate || !claimedAsDependant) return null;
return ageOn(birthDate, TAX_YEAR_END) >= 24
? { dependantTooOld: { maxAge: 24 } }
: null;
};Return a structured error object, not a message string. The validator's job is to say what's wrong; the template's job is to phrase it for a human. Mixing those means your validation logic can't be unit-tested without asserting on English prose, and it can't be translated.
One error component, everywhere
Sixty fields means sixty opportunities to phrase an error differently. We built a single component that takes a control and renders the first error from a shared message map, and used it for every field without exception.
<app-field label="Social Security Number" [control]="form.controls.ssn">
<input formControlName="ssn" inputmode="numeric" />
</app-field>Consistent spacing, consistent aria-describedby wiring, consistent timing on when errors appear (on blur, not on every keystroke — nothing is more hostile than being told your email is invalid while you're typing the third character).
What I'd never repeat
- A fully JSON-driven form engine. We prototyped one. It handled 80% of cases beautifully and made the last 20% nearly impossible, and the last 20% is where the business rules live.
- Validating on every keystroke.
updateOn: 'blur'for most fields,'change'only where live feedback genuinely helps, like a password strength meter. - Storing draft state only in the form. A long form needs to survive a refresh. Serialise
form.getRawValue()on a debounce and restore it on load. - Disabling the submit button on invalid. Let them submit, then focus the first invalid field and explain what's wrong. A disabled button with no explanation is a dead end.
That last one is a hill I'll die on. Users don't experience a disabled button as "the form is incomplete". They experience it as "the site is broken".