Reactive Forms: Validators, Groups, and Arrays
13 min read
Going Deeper with Reactive Forms
Reactive forms shine when you need programmatic control — dynamic fields, cross-field validation, or complex nested structures. FormGroup, FormArray, and custom ValidatorFn functions give you full control from the TypeScript class.
Cross-Field Validation with a Custom Validator
ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Custom validator: password and confirmPassword must match
export const passwordMatchValidator: ValidatorFn = (
group: AbstractControl
): ValidationErrors | null => {
const pw = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return pw === confirm ? null : { passwordMismatch: true };
};ts
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormArray, Validators } from '@angular/forms';
import { passwordMatchValidator } from './password-match.validator';
@Component({
selector: 'app-registration',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" type="email" placeholder="Email" />
<input formControlName="password" type="password" placeholder="Password" />
<input formControlName="confirmPassword" type="password" placeholder="Confirm" />
@if (form.hasError('passwordMismatch')) {
<p class="error">Passwords do not match.</p>
}
<h4>Phone numbers</h4>
<div formArrayName="phones">
@for (ctrl of phones.controls; track $index) {
<input [formControlName]="$index" placeholder="Phone" />
}
</div>
<button type="button" (click)="addPhone()">Add phone</button>
<button type="submit" [disabled]="form.invalid">Register</button>
</form>
`
})
export class RegistrationComponent {
private fb = inject(FormBuilder);
form = this.fb.group(
{
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
phones: this.fb.array([this.fb.control('')])
},
{ validators: passwordMatchValidator }
);
get phones() {
return this.form.get('phones') as FormArray;
}
addPhone() {
this.phones.push(this.fb.control(''));
}
onSubmit() {
if (this.form.valid) console.log(this.form.value);
}
}Pass a group-level validator as the second argument to fb.group({ ... }, { validators: myFn }). The validator receives the entire FormGroup, so it can compare sibling controls.
Async Validators
Use AsyncValidatorFn when validation requires a server round-trip (e.g. checking username availability). Return an Observable or Promise that resolves to ValidationErrors | null. Pass it as the third argument to FormControl.
ts
import { AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, catchError, of, debounceTime, switchMap, first } from 'rxjs';
export function uniqueUsernameValidator(): AsyncValidatorFn {
const http = inject(HttpClient);
return (control: AbstractControl) =>
control.valueChanges.pipe(
debounceTime(400),
switchMap(value =>
http.get<{ taken: boolean }>(`/api/check-username?q=${value}`)
),
map(res => (res.taken ? { usernameTaken: true } : null)),
catchError(() => of(null)),
first()
);
}Ready to test yourself?
Practice Angular— quiz & coding exercises