Forms
Template-driven vs reactive forms, validators, custom validators, form arrays
Angular provides two form approaches: Template-driven (simple, two-way binding with ngModel) and Reactive (explicit, programmatic, testable). Reactive Forms are the standard for production apps — they provide synchronous access to form values, composable validators, and easier testing without a DOM.
Key Points
- Template-driven: [(ngModel)] binds to a variable, FormsModule required — simple but hard to test and dynamically build
- Reactive: FormGroup / FormControl / FormArray from ReactiveFormsModule — explicit model, synchronous, type-safe
- FormControl<T>: typed since Angular 14 — FormControl<string | null> prevents type errors
- Validators: Validators.required, Validators.minLength(3), Validators.email, Validators.pattern(regex)
- Custom validator: function that takes AbstractControl and returns ValidationErrors | null
- Async validator: returns Promise<ValidationErrors | null> or Observable — for server-side uniqueness checks
- Cross-field validation: validator on FormGroup — compare two fields (password === confirmPassword)
- FormArray: dynamic list of controls — add/remove items at runtime (multi-item forms)
- valueChanges: Observable of form value updates — react to changes with RxJS
Angular Reactive Forms: typed controls, async email validator, cross-field password match, error display pattern
import { FormBuilder, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
// Typed reactive form
@Component({
standalone: true,
imports: [ReactiveFormsModule, NgIf],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="email" placeholder="Email">
@if (form.get('email')?.invalid && form.get('email')?.touched) {
<span class="error">{{ emailError }}</span>
}
<div formGroupName="passwords">
<input type="password" formControlName="password">
<input type="password" formControlName="confirm">
@if (form.get('passwords')?.hasError('mismatch')) {
<span class="error">Passwords do not match</span>
}
</div>
<button [disabled]="form.invalid">Register</button>
</form>
`
})
export class RegisterComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email], [this.uniqueEmailValidator()]],
passwords: this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirm: ['', Validators.required]
}, { validators: passwordMatchValidator })
});
get emailError(): string {
const ctrl = this.form.get('email')!;
if (ctrl.hasError('required')) return 'Email is required';
if (ctrl.hasError('email')) return 'Invalid email format';
if (ctrl.hasError('taken')) return 'Email already registered';
return '';
}
submit() {
if (this.form.valid) console.log(this.form.getRawValue());
}
// Async validator — check email uniqueness
uniqueEmailValidator(): AsyncValidatorFn {
const svc = inject(UserService);
return (ctrl: AbstractControl) =>
ctrl.valueChanges.pipe(
debounceTime(400),
switchMap(email => svc.isEmailTaken(email)),
map(taken => taken ? { taken: true } : null),
first()
);
}
}
// Cross-field validator
function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
const pw = group.get('password')?.value;
const cfm = group.get('confirm')?.value;
return pw === cfm ? null : { mismatch: true };
}Real-World Example
Reactive Forms make form logic unit-testable without a DOM — you can call form.setValue({...}), trigger validators, and assert on form.errors without rendering anything. Template-driven forms require TestBed and async fixture stabilisation for the same level of testing. For any form with more than 3 fields or custom validation logic, reactive forms pay off immediately.