Angular: Zero to Senior · Lesson 6 of 11
Reactive Forms & Validation
Reactive forms are the professional way to build forms in Angular. The form model lives in the component class — not in the template — making it fully testable, predictable, and composable.
Setup
Import ReactiveFormsModule in your component:
TYPESCRIPT
import { ReactiveFormsModule } from '@angular/forms';
@Component({
standalone: true,
imports: [ReactiveFormsModule],
// ...
})FormBuilder Basics
TYPESCRIPT
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { inject } from '@angular/core';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
rememberMe: [false],
});
onSubmit(): void {
if (this.form.invalid) return;
const { email, password, rememberMe } = this.form.value;
console.log(email, password, rememberMe);
}
}Template:
HTML
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label>Email</label>
<input type="email" formControlName="email" />
@if (form.controls.email.invalid && form.controls.email.touched) {
@if (form.controls.email.errors?.['required']) {
<span class="error">Email is required</span>
}
@if (form.controls.email.errors?.['email']) {
<span class="error">Invalid email address</span>
}
}
</div>
<div>
<label>Password</label>
<input type="password" formControlName="password" />
@if (form.controls.password.invalid && form.controls.password.touched) {
<span class="error">Password must be at least 8 characters</span>
}
</div>
<label>
<input type="checkbox" formControlName="rememberMe" />
Remember me
</label>
<button type="submit" [disabled]="form.invalid">Login</button>
</form>Built-in Validators
TYPESCRIPT
import { Validators } from '@angular/forms';
this.fb.group({
name: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]],
email: ['', [Validators.required, Validators.email]],
age: [null, [Validators.required, Validators.min(18), Validators.max(120)]],
website: ['', Validators.pattern(/^https?:\/\/.+/)],
phone: ['', Validators.pattern(/^\+?[\d\s-]{10,}$/)],
});Custom Validators
Synchronous custom validator:
TYPESCRIPT
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function noSpacesValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const hasSpaces = /\s/.test(control.value ?? '');
return hasSpaces ? { noSpaces: { value: control.value } } : null;
};
}Cross-field validator (passwords must match):
TYPESCRIPT
export function passwordMatchValidator(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirmPassword = group.get('confirmPassword')?.value;
return password === confirmPassword ? null : { passwordMismatch: true };
};
}Apply to the group:
TYPESCRIPT
form = this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator() });In the template:
HTML
@if (form.errors?.['passwordMismatch'] && form.controls.confirmPassword.touched) {
<span class="error">Passwords do not match</span>
}Async Validators
Check uniqueness against an API:
TYPESCRIPT
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, map, catchError, of, debounceTime, switchMap, first } from 'rxjs';
export function usernameAvailableValidator(userService: UserService): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
return control.valueChanges.pipe(
debounceTime(400),
switchMap(value => userService.checkUsernameAvailable(value)),
map(available => available ? null : { usernameTaken: true }),
catchError(() => of(null)),
first(),
);
};
}TYPESCRIPT
username: ['', Validators.required, usernameAvailableValidator(this.userService)],HTML
@if (form.controls.username.pending) {
<span>Checking availability...</span>
}
@if (form.controls.username.errors?.['usernameTaken']) {
<span class="error">Username is already taken</span>
}FormArray — Dynamic Fields
TYPESCRIPT
import { FormBuilder, FormArray, Validators } from '@angular/forms';
@Component({ /* ... */ })
export class InviteTeamComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
projectName: ['', Validators.required],
members: this.fb.array([
this.createMemberControl(), // start with one
]),
});
get members(): FormArray {
return this.form.get('members') as FormArray;
}
createMemberControl() {
return this.fb.group({
email: ['', [Validators.required, Validators.email]],
role: ['viewer', Validators.required],
});
}
addMember(): void {
this.members.push(this.createMemberControl());
}
removeMember(index: number): void {
this.members.removeAt(index);
}
}Template:
HTML
<form [formGroup]="form">
<input formControlName="projectName" placeholder="Project name" />
<div formArrayName="members">
@for (member of members.controls; track $index; let i = $index) {
<div [formGroupName]="i" class="member-row">
<input formControlName="email" placeholder="Email" />
<select formControlName="role">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button type="button" (click)="removeMember(i)">Remove</button>
</div>
}
</div>
<button type="button" (click)="addMember()">+ Add Member</button>
<button type="submit" [disabled]="form.invalid">Send Invites</button>
</form>Watching Value Changes
React to form value changes without submitting:
TYPESCRIPT
ngOnInit(): void {
// Watch a single control
this.form.controls.email.valueChanges.subscribe(value => {
console.log('Email changed:', value);
});
// Watch the whole form
this.form.valueChanges.subscribe(value => {
console.log('Form value:', value);
this.autoSave(value);
});
// Debounce heavy operations
this.form.controls.searchTerm.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
).subscribe(term => this.search(term));
}Patching Values
TYPESCRIPT
// patchValue — update specific fields (others keep their current values)
this.form.patchValue({
email: 'alice@example.com',
rememberMe: true,
});
// setValue — must provide ALL fields
this.form.setValue({
email: 'alice@example.com',
password: '',
rememberMe: false,
});
// Reset — clear all values and reset touched/dirty state
this.form.reset();
// Reset with specific values
this.form.reset({ email: '', password: '' });Form State
TYPESCRIPT
// Validity
form.valid // all controls pass validators
form.invalid // at least one control fails
form.pending // async validator running
// Interaction
form.touched // user has focused + blurred at least one field
form.untouched
form.dirty // any value has changed
form.pristine // no values changed
// Per-control
form.controls.email.valid
form.controls.email.errors // null or { required: true, email: true, ... }
form.controls.email.hasError('required')Full Registration Form Example
TYPESCRIPT
@Component({ /* ... */ })
export class RegisterComponent {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
isLoading = false;
serverError = '';
form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
agreeToTerms: [false, Validators.requiredTrue],
}, { validators: passwordMatchValidator() });
async onSubmit(): Promise<void> {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.isLoading = true;
this.serverError = '';
try {
await this.auth.register(this.form.value as RegisterRequest);
this.router.navigate(['/dashboard']);
} catch (err: any) {
this.serverError = err.message ?? 'Registration failed. Try again.';
} finally {
this.isLoading = false;
}
}
}Quick Reference
Setup: imports: [ReactiveFormsModule]
Create: fb.group({ field: [value, validators] })
Bind: [formGroup]="form", formControlName="field"
Submit: (ngSubmit)="onSubmit()"
Validity: form.valid, form.invalid, control.errors?.['required']
Patch: form.patchValue({ field: value })
Reset: form.reset()
Dynamic fields: FormArray + fb.array([]) + formArrayName
Custom validator: ValidatorFn → returns ValidationErrors | null
Async validator: AsyncValidatorFn → returns Observable
Cross-field: Validator on the group, not individual control
Watch changes: control.valueChanges.pipe(debounceTime(300)).subscribe() Lesson Checkpoint