Skip to main content
Published / updated

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, FormArray
  • FormBuilder and typed forms
  • Binding to the template
  • Validators
  • Custom and cross-field validation
  • Async validators
  • valueChanges and statusChanges
  • FormArray for 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) })
]);
TypeRepresents
FormControlOne field
FormGroupAn object of controls
FormArrayA 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

MessageCauseFix
Cannot find control with name 'x'formControlName does not match the groupMatch them exactly
formGroup expects a FormGroup instancePassed something elseCheck the binding
Values are typed anyNot using nonNullable typed formsUse fb.nonNullable.group
A cross-field rule never firesValidator on a control, not the groupPut it on the group
Server errors are not shown per field400 errors object not mappedMap 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

  • FormsModule instead of ReactiveFormsModule
  • Omitting nonNullable, so reset() produces null
  • Changing validators without updateValueAndValidity()
  • Expecting form.value to include disabled controls
  • No takeUntilDestroyed on a valueChanges subscription
  • A valueChanges handler writing to the form without emitEvent: false
  • patchValue where setValue would have caught a missing field
  • Cross-field rules attached to a control instead of the group
  • A custom validator not returning null for an empty value
  • No debounce or cancellation on an async validator
  • Async validator without excludePublicId in edit mode, conflicting with itself
  • No markAllAsTouched() on submit
  • Validators.min on a string control
  • Missing formArrayName or [formGroupName]="i"

Practice

The course exercise is create a validated form, done reactively.

  1. Rebuild the student form with fb.nonNullable.group and full validators.
  2. Omit nonNullable, call reset(), and inspect the values. Add it back and compare.
  3. Write minAgeValidator and unit-test it with no TestBed.
  4. Write marksOrAbsentValidator on a group. Confirm the error appears on form.errors.
  5. Disable marksObtained when isAbsent is ticked. Forget updateValueAndValidity() and observe the stale validity.
  6. Submit with a disabled control and confirm it is missing from form.value. Use getRawValue().
  7. Subscribe to valueChanges with no takeUntilDestroyed. Navigate away and back ten times, and watch the handler fire repeatedly.
  8. Add takeUntilDestroyed and confirm one handler.
  9. Write the async unique-roll-number validator with updateOn: 'blur', debounce and cancellation. Show the pending state.
  10. Load a student for editing without excludePublicId and save without changing the roll number. Confirm the false conflict.
  11. Build the exam-results screen with a FormArray of 40 rows.
  12. Use patchValue to load a record, then add a field to the form and reload. Confirm the new field is silently blank. Switch to setValue and confirm the throw.
  13. Map a 400 errors object to individual controls.
  14. 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 FormArray for a repeating section
  • Say why nonNullable matters

Review questions

  1. What does nonNullable change about reset() and typing?
  2. Why must updateValueAndValidity() follow a validator change?
  3. Why does form.value omit disabled controls, and what is the alternative?
  4. Why is takeUntilDestroyed required on a valueChanges subscription?

Next: RxJS basics