Reactive Forms
Before you start
You need: template-driven forms (Article 06).
Time: about 50 minutes, plus the practice.
Learning objective
Build a typed reactive form with custom and cross-field validation, dynamic controls, and correct submit handling.
Topics
FormControl,FormGroup,FormArrayFormBuilderand typed forms- Binding to the template
- Validators
- Custom and cross-field validation
- Async validators
valueChangesandstatusChangesFormArrayfor dynamic fields- Patching and resetting
- Testing
The building blocks
import { FormControl, FormGroup, FormArray, Validators } from '@angular/forms';
const nameControl = new FormControl('', { nonNullable: true, validators: [Validators.required] });
const form = new FormGroup({
name: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
rollNumber: new FormControl('', { nonNullable: true })
});
const results = new FormArray([
new FormGroup({ studentId: new FormControl(0), marks: new FormControl<number | null>(null) })
]);
| Type | Represents |
|---|---|
FormControl | One field |
FormGroup | An object of controls |
FormArray | A dynamic list of controls |
nonNullable: true matters. Without it, reset() sets the control to null rather than its initial value, and the type becomes string | null. With it, the control is typed string and resets to ''.
FormBuilder
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-student-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './student-form.component.html'
})
export class StudentFormComponent {
private readonly fb = inject(FormBuilder);
readonly form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.maxLength(100)]],
rollNumber: ['', [Validators.required, Validators.pattern(/^NCA-\d{4}-\d{4}$/)]],
className: ['', Validators.required],
section: ['', Validators.required],
dateOfBirth: ['', Validators.required],
parentName: ['', [Validators.required, Validators.maxLength(100)]],
parentPhone: ['', [Validators.required, Validators.pattern(/^[6-9]\d{9}$/)]],
address: ['']
});
}
fb.nonNullable.group(...) makes every control non-nullable at once. The form is then fully typed:
this.form.value.name; // string
this.form.value.notAField; // compile error
this.form.controls.name; // FormControl<string>
Typed forms are the reason to prefer reactive: a renamed field is a compile error rather than a silent undefined.
ReactiveFormsModule, not FormsModule — using the wrong one gives "Can't bind to 'formGroup'".
Binding to the template
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
<div class="field">
<label for="name">Student name</label>
<input id="name" type="text" formControlName="name"
[attr.aria-invalid]="isInvalid('name')"
aria-describedby="nameError">
@if (isInvalid('name')) {
<p id="nameError" class="error" role="alert">
@if (form.controls.name.errors?.['required']) {
Student name is required.
} @else if (form.controls.name.errors?.['maxlength']) {
Name cannot exceed 100 characters.
}
</p>
}
</div>
<button type="submit" [disabled]="saving()">Save</button>
<button type="button" (click)="cancel()">Cancel</button>
</form>
isInvalid(controlName: string): boolean {
const control = this.form.get(controlName);
return !!control && control.invalid && (control.touched || this.submitted());
}
formControlName links the input to the control by name. No [(ngModel)], no name attribute, no template reference variable — the form's shape lives entirely in the class.
Nested groups:
readonly form = this.fb.nonNullable.group({
name: ['', Validators.required],
address: this.fb.nonNullable.group({
line1: [''],
city: ['', Validators.required],
pinCode: ['', Validators.pattern(/^\d{6}$/)]
})
});
<div formGroupName="address">
<input formControlName="city">
<input formControlName="pinCode">
</div>
Built-in validators
Validators.required
Validators.requiredTrue // for a checkbox that must be ticked
Validators.email
Validators.min(0)
Validators.max(100)
Validators.minLength(2)
Validators.maxLength(100)
Validators.pattern(/^NCA-\d{4}-\d{4}$/)
Validators.nullValidator
name: ['', [Validators.required, Validators.maxLength(100)]]
Validators.min and max work on numbers only — on a string control they do nothing, silently. That catches people using them on a text input holding digits.
Custom validators
A plain function, which is the main practical advantage over template-driven forms.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function minAgeValidator(minAge: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) {
return null; // let `required` handle absence
}
const dateOfBirth = new Date(control.value);
if (Number.isNaN(dateOfBirth.getTime())) {
return { invalidDate: true };
}
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) {
age--;
}
return age >= minAge ? null : { minAge: { required: minAge, actual: age } };
};
}
dateOfBirth: ['', [Validators.required, minAgeValidator(3)]]
A validator returns null for valid, or an object whose keys name the failures. The error object can carry data, which the template uses for the message:
@if (form.controls.dateOfBirth.errors?.['minAge']; as err) {
<p class="error" role="alert">Student must be at least {{ err.required }} years old.</p>
}
Returning null for an empty value avoids duplicating required — otherwise an omitted field produces two messages.
This function is testable with no DOM and no TestBed:
expect(minAgeValidator(3)(new FormControl('2023-01-01'))).toEqual({ minAge: { required: 3, actual: 2 } });
Cross-field validation
Rules spanning two controls go on the group, not on a control.
export function marksOrAbsentValidator(group: AbstractControl): ValidationErrors | null {
const isAbsent = group.get('isAbsent')?.value as boolean;
const marks = group.get('marksObtained')?.value as number | null;
if (isAbsent && marks !== null) {
return { marksWhenAbsent: true };
}
if (!isAbsent && marks === null) {
return { marksRequired: true };
}
return null;
}
readonly form = this.fb.nonNullable.group({
studentId: [0, Validators.required],
marksObtained: this.fb.control<number | null>(null),
isAbsent: [false]
}, { validators: marksOrAbsentValidator });
@if (form.errors?.['marksRequired'] && submitted()) {
<p class="error" role="alert">Enter marks, or mark the student absent.</p>
}
Group-level errors live on form.errors, not on any control — so the message belongs near the group rather than beside one field.
This is the rule template-driven forms handle badly, and the clearest reason to reach for reactive forms.
Conditional validation, applied when another control changes:
ngOnInit(): void {
this.form.controls.isAbsent.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(isAbsent => {
const marks = this.form.controls.marksObtained;
if (isAbsent) {
marks.clearValidators();
marks.setValue(null);
marks.disable();
} else {
marks.setValidators([Validators.required, Validators.min(0), Validators.max(100)]);
marks.enable();
}
marks.updateValueAndValidity();
});
}
updateValueAndValidity() is required after changing validators. Without it the control keeps its previous validity, and the form's state is wrong with no visible cause.
Note that form.value excludes disabled controls. Use form.getRawValue() when you need every field:
const payload = this.form.getRawValue();
That surprises people the first time — a disabled field vanishes from the submitted object.
Async validators
export function uniqueRollNumberValidator(
studentService: StudentService, excludePublicId?: string): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
return timer(400).pipe(
switchMap(() => studentService.rollNumberExists(control.value, excludePublicId)),
map(exists => exists ? { rollNumberTaken: true } : null),
catchError(() => of(null))
);
};
}
rollNumber: ['', {
validators: [Validators.required, Validators.pattern(/^NCA-\d{4}-\d{4}$/)],
asyncValidators: [uniqueRollNumberValidator(this.studentService)],
updateOn: 'blur'
}]
updateOn: 'blur' runs validation when the field loses focus rather than on every keystroke — right for anything touching the network.
While it runs, the control's status is PENDING:
@if (form.controls.rollNumber.pending) {
<small role="status">Checking availability…</small>
}
catchError returning null means a network failure does not block the form. The server check on submit is the real guarantee.
excludePublicId is the edit-mode detail: without it, saving a student without changing the roll number reports a conflict with itself.
valueChanges and statusChanges
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.form.controls.className.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(className => this.loadSections(className));
this.form.valueChanges
.pipe(
debounceTime(500),
takeUntilDestroyed(this.destroyRef))
.subscribe(value => this.saveDraft(value));
this.form.statusChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(status => this.canSave.set(status === 'VALID'));
}
takeUntilDestroyed is not optional. A valueChanges subscription with no teardown keeps the component alive after navigation — a memory leak that grows with every visit to the page. The async pipe handles this automatically; a manual subscription does not.
emitEvent: false prevents a loop when a subscriber writes back to the form:
this.form.controls.section.setValue('A', { emitEvent: false });
Without it, setting a value inside a valueChanges handler triggers the handler again.
FormArray
Dynamic controls — the case template-driven forms cannot express cleanly.
readonly form = this.fb.nonNullable.group({
examId: [0, Validators.required],
results: this.fb.array<FormGroup>([])
});
get results(): FormArray {
return this.form.controls.results;
}
addResult(student: Student): void {
this.results.push(this.fb.nonNullable.group({
studentId: [student.id],
studentName: [{ value: student.name, disabled: true }],
marksObtained: this.fb.control<number | null>(null, [Validators.min(0), Validators.max(100)]),
isAbsent: [false]
}, { validators: marksOrAbsentValidator }));
}
removeResult(index: number): void {
this.results.removeAt(index);
}
<div formArrayName="results">
@for (group of results.controls; track $index; let i = $index) {
<div [formGroupName]="i" class="result-row">
<span>{{ group.get('studentName')?.value }}</span>
<input type="number" formControlName="marksObtained"
[attr.aria-label]="'Marks for ' + group.get('studentName')?.value">
<label>
<input type="checkbox" formControlName="isAbsent"> Absent
</label>
<button type="button" (click)="removeResult(i)">Remove</button>
</div>
}
</div>
formArrayName on the container, [formGroupName]="i" on each row. track $index is correct here because the controls have no stable identity of their own and the whole array is rebuilt together.
Forty students, forty control groups, each independently validated — and one form.valid covering all of them.
Patching and resetting
// setValue — every control must be supplied, or it throws
this.form.setValue({
name: student.name,
rollNumber: student.rollNumber,
className: student.className,
section: student.section,
dateOfBirth: student.dateOfBirth,
parentName: student.parentName,
parentPhone: student.parentPhone,
address: student.address ?? ''
});
// patchValue — supply only what you have
this.form.patchValue({ name: student.name, className: student.className });
setValue throwing on a missing control is a feature. Adding a field to the form and forgetting to load it becomes a loud error rather than a silently blank input. Use setValue when loading a full record.
this.form.reset(); // back to initial values
this.form.reset({ className: '10th', section: 'A' }); // reset with defaults
this.form.markAsPristine();
this.form.markAllAsTouched();
With nonNullable, reset() returns each control to its initial value. Without it, every control becomes null — which is why nonNullable was the first thing in this article.
Loading an existing record for editing:
ngOnInit(): void {
const publicId = this.route.snapshot.paramMap.get('publicId');
if (!publicId) {
return;
}
this.studentService.getByPublicId(publicId).subscribe(student => {
this.form.setValue({ /* every field */ });
this.form.markAsPristine();
});
}
markAsPristine() after loading means an unsaved-changes guard does not fire immediately.
Submitting
submitted = signal(false);
saving = signal(false);
serverError = signal<string | null>(null);
onSubmit(): void {
this.submitted.set(true);
if (this.form.invalid) {
this.form.markAllAsTouched();
this.focusFirstInvalid();
return;
}
this.saving.set(true);
this.serverError.set(null);
this.studentService.create(this.form.getRawValue()).subscribe({
next: () => this.router.navigate(['/students'], { queryParams: { saved: '1' } }),
error: (err: HttpErrorResponse) => {
this.saving.set(false);
if (err.status === 400 && err.error?.errors) {
this.applyServerErrors(err.error.errors);
return;
}
this.serverError.set(err.status === 409
? 'This roll number is already in use.'
: 'Could not save. Please try again.');
}
});
}
private applyServerErrors(errors: Record<string, string[]>): void {
for (const [field, messages] of Object.entries(errors)) {
const key = field.charAt(0).toLowerCase() + field.slice(1);
const control = this.form.get(key);
if (control && messages[0]) {
control.setErrors({ server: messages[0] });
control.markAsTouched();
}
}
}
ASP.NET Core returns errors keyed by PascalCase property name; the camelCase conversion maps them to controls. Field-level messages beat one generic banner.
Testing
it('rejects a roll number in the wrong format', () => {
const fb = new FormBuilder();
const form = fb.nonNullable.group({
rollNumber: ['', [Validators.required, Validators.pattern(/^NCA-\d{4}-\d{4}$/)]]
});
form.controls.rollNumber.setValue('12345');
expect(form.controls.rollNumber.hasError('pattern')).toBe(true);
});
it('requires marks when the student is not absent', () => {
const group = new FormGroup({
marksObtained: new FormControl<number | null>(null),
isAbsent: new FormControl(false)
}, { validators: marksOrAbsentValidator });
expect(group.hasError('marksRequired')).toBe(true);
});
No component, no template, no TestBed. The form is a plain object graph, which is why reactive forms are testable in a way template-driven forms are not.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
Cannot find control with name 'x' | formControlName does not match the group | Match them exactly |
formGroup expects a FormGroup instance | Passed something else | Check the binding |
Values are typed any | Not using nonNullable typed forms | Use fb.nonNullable.group |
| A cross-field rule never fires | Validator on a control, not the group | Put it on the group |
| Server errors are not shown per field | 400 errors object not mapped | Map it onto the controls |
Map the API's 400 errors object back onto individual controls. A single "save failed" message tells the user nothing about which field to fix.
Common mistakes
FormsModuleinstead ofReactiveFormsModule- Omitting
nonNullable, soreset()producesnull - Changing validators without
updateValueAndValidity() - Expecting
form.valueto include disabled controls - No
takeUntilDestroyedon avalueChangessubscription - A
valueChangeshandler writing to the form withoutemitEvent: false patchValuewheresetValuewould have caught a missing field- Cross-field rules attached to a control instead of the group
- A custom validator not returning
nullfor an empty value - No debounce or cancellation on an async validator
- Async validator without
excludePublicIdin edit mode, conflicting with itself - No
markAllAsTouched()on submit Validators.minon a string control- Missing
formArrayNameor[formGroupName]="i"
Practice
The course exercise is create a validated form, done reactively.
- Rebuild the student form with
fb.nonNullable.groupand full validators. - Omit
nonNullable, callreset(), and inspect the values. Add it back and compare. - Write
minAgeValidatorand unit-test it with no TestBed. - Write
marksOrAbsentValidatoron a group. Confirm the error appears onform.errors. - Disable
marksObtainedwhenisAbsentis ticked. ForgetupdateValueAndValidity()and observe the stale validity. - Submit with a disabled control and confirm it is missing from
form.value. UsegetRawValue(). - Subscribe to
valueChangeswith notakeUntilDestroyed. Navigate away and back ten times, and watch the handler fire repeatedly. - Add
takeUntilDestroyedand confirm one handler. - Write the async unique-roll-number validator with
updateOn: 'blur', debounce and cancellation. Show the pending state. - Load a student for editing without
excludePublicIdand save without changing the roll number. Confirm the false conflict. - Build the exam-results screen with a
FormArrayof 40 rows. - Use
patchValueto load a record, then add a field to the form and reload. Confirm the new field is silently blank. Switch tosetValueand confirm the throw. - Map a 400
errorsobject to individual controls. - Write three unit tests for form validity with no component involved.
Exercises 6, 7 and 12 correspond to three real defects — a missing field on save, a memory leak, and a silently blank input.
You can now
- Build a typed reactive form
- Write custom and cross-field validators
- Map server validation errors to individual controls
- Use
FormArrayfor a repeating section - Say why
nonNullablematters
Review questions
- What does
nonNullablechange aboutreset()and typing? - Why must
updateValueAndValidity()follow a validator change? - Why does
form.valueomit disabled controls, and what is the alternative? - Why is
takeUntilDestroyedrequired on avalueChangessubscription?
Next: RxJS basics