Docs
/
Angular
Chapter 7
07 — Reactive Forms
Why Reactive Forms?
| Reactive Forms | Template-Driven Forms | |
|---|---|---|
| Setup | In TypeScript | In template (ngModel) |
| Validation | Programmatic | Directive-based |
| Testability | Easy to unit test | Harder |
| Dynamic fields | Easy (FormArray) | Difficult |
| Complexity | Best for complex forms | Best for simple forms |
Setup
import { ReactiveFormsModule } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule], // Required for reactive forms
})
FormControl, FormGroup, FormBuilder
Basic FormGroup
import { FormGroup, FormControl, Validators } from '@angular/forms';
@Component({
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" placeholder="Name">
@if (form.controls.name.errors?.['required'] && form.controls.name.touched) {
<span class="error">Name is required</span>
}
<input formControlName="email" placeholder="Email">
@if (form.controls.email.errors?.['email'] && form.controls.email.touched) {
<span class="error">Invalid email</span>
}
<button [disabled]="form.invalid">Submit</button>
</form>
`,
})
export class UserFormComponent {
form = new FormGroup({
name: new FormControl('', [Validators.required, Validators.minLength(2)]),
email: new FormControl('', [Validators.required, Validators.email]),
age: new FormControl<number | null>(null, [Validators.min(0), Validators.max(150)]),
});
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
// → { name: 'Alice', email: 'alice@test.com', age: 28 }
}
}
}
FormBuilder (Less Verbose)
import { FormBuilder, Validators } from '@angular/forms';
@Component({ ... })
export class UserFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
age: [null as number | null, [Validators.min(0)]],
});
}
NonNullableFormBuilder (No null Resets)
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
name: ['', Validators.required], // Reset goes to '' not null
email: ['', Validators.required],
});
this.form.reset(); // → { name: '', email: '' } (not null)
Built-in Validators
| Validator | Usage |
|---|---|
Validators.required | Field must have a value |
Validators.email | Must be a valid email |
Validators.min(n) | Number ≥ n |
Validators.max(n) | Number ≤ n |
Validators.minLength(n) | String length ≥ n |
Validators.maxLength(n) | String length ≤ n |
Validators.pattern(regex) | Match regex pattern |
Validators.requiredTrue | Must be true (checkboxes) |
Custom Validators
Sync Validator
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Factory function
export function noWhitespace(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) return null;
const hasWhitespace = control.value.trim().length === 0;
return hasWhitespace ? { whitespace: true } : null;
};
}
export function matchField(fieldName: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const parent = control.parent;
if (!parent) return null;
const field = parent.get(fieldName);
return field && control.value !== field.value
? { mismatch: { field: fieldName } }
: null;
};
}
// Usage
form = this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', [Validators.required, matchField('password')]],
username: ['', [Validators.required, noWhitespace()]],
});
Async Validator
import { AsyncValidatorFn } from '@angular/forms';
export function uniqueEmail(userService: UserService): AsyncValidatorFn {
return (control: AbstractControl) => {
return userService.checkEmail(control.value).pipe(
map(exists => exists ? { emailTaken: true } : null),
catchError(() => of(null)),
);
};
}
// Usage
email: ['', [Validators.required, Validators.email], [uniqueEmail(this.userService)]],
// ↑ Third array = async validators
Cross-Field Validator (Group-Level)
export function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
}
form = this.fb.group({
password: ['', Validators.required],
confirmPassword: ['', Validators.required],
}, { validators: [passwordMatchValidator] });
// Template
@if (form.errors?.['passwordMismatch']) {
<span class="error">Passwords do not match</span>
}
FormArray — Dynamic Fields
@Component({
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name">
<div formArrayName="phones">
@for (phone of phones.controls; track $index; let i = $index) {
<div>
<input [formControlName]="i" placeholder="Phone {{ i + 1 }}">
<button type="button" (click)="removePhone(i)">×</button>
</div>
}
</div>
<button type="button" (click)="addPhone()">+ Add Phone</button>
<button [disabled]="form.invalid">Submit</button>
</form>
`,
})
export class ContactFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
name: ['', Validators.required],
phones: this.fb.array([this.fb.control('', Validators.required)]),
});
get phones() { return this.form.controls.phones; }
addPhone() {
this.phones.push(this.fb.control('', Validators.required));
}
removePhone(index: number) {
this.phones.removeAt(index);
}
}
FormArray of FormGroups
form = this.fb.group({
items: this.fb.array<FormGroup>([]),
});
get items() { return this.form.controls.items; }
addItem() {
this.items.push(this.fb.group({
productId: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
price: [0, [Validators.required, Validators.min(0)]],
}));
}
Displaying Errors (Reusable)
// shared/form-error.component.ts
@Component({
selector: 'app-form-error',
standalone: true,
template: `
@if (control?.invalid && (control?.dirty || control?.touched)) {
<div class="error">
@if (control?.errors?.['required']) { <span>This field is required</span> }
@if (control?.errors?.['email']) { <span>Invalid email</span> }
@if (control?.errors?.['minlength']) {
<span>Min {{ control.errors?.['minlength'].requiredLength }} characters</span>
}
@if (control?.errors?.['emailTaken']) { <span>Email already taken</span> }
</div>
}
`,
})
export class FormErrorComponent {
@Input() control: AbstractControl | null = null;
}
<input formControlName="email">
<app-form-error [control]="form.controls.email" />
Patching & Resetting
// Set all values (must include all fields)
this.form.setValue({ name: 'Alice', email: 'alice@test.com', age: 28 });
// Set some values (partial)
this.form.patchValue({ name: 'Alice' });
// Reset to initial state
this.form.reset();
// Reset with specific values
this.form.reset({ name: 'Default', email: '' });
// Mark all as touched (trigger validation display)
this.form.markAllAsTouched();
Key Takeaways
- Use Reactive Forms for anything beyond a simple search input
- Use
NonNullableFormBuilderto avoidnullon form reset - Custom validators return
nullfor valid,{ errorKey: true }for invalid - Async validators go in the third argument of
FormControl FormArrayhandles dynamic lists of fields (phones, addresses, order items)- Create a reusable
FormErrorComponentto DRY up validation messages - Always call
markAllAsTouched()on submit to show all validation errors