Angular Forms - Quick Reference
Deep Knowledge: Use mcp__documentation__fetch_docs with technology: angular, topic: forms for comprehensive documentation.
Typed Reactive Forms (Angular 14+)
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" />
<input formControlName="email" type="email" />
<button type="submit" [disabled]="form.invalid">Save</button>
</form>
`
})
export class UserFormComponent {
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
age: [0, [Validators.min(0), Validators.max(150)]],
});
onSubmit() {
if (this.form.valid) {
const value = this.form.getRawValue(); // Typed!
console.log(value.name); // string, not string | null
}
}
}
FormArray
form = this.fb.group({
name: ['', Validators.required],
addresses: this.fb.array([this.createAddress()]),
});
createAddress() {
return this.fb.group({
street: ['', Validators.required],
city: ['', Validators.required],
zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
});
}
get addresses() {
return this.form.get('addresses') as FormArray;
}
addAddress() {
this.addresses.push(this.createAddress());
}
removeAddress(index: number) {
this.addresses.removeAt(index);
}
Custom Validators
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Sync validator
export function forbiddenName(name: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = control.value === name;
return forbidden ? { forbiddenName: { value: control.value } } : null;
};
}
// Async validator
export function uniqueEmail(userService: UserService): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
return userService.checkEmail(control.value).pipe(
map(exists => exists ? { emailTaken: true } : null),
catchError(() => of(null))
);
};
}
// Cross-field validator
export const passwordMatchValidator: ValidatorFn = (form: AbstractControl) => {
const password = form.get('password')?.value;
const confirm = form.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
};
Error Display Pattern
@Component({
template: `
<input formControlName="email" />
@if (form.controls.email.hasError('required') && form.controls.email.touched) {
<span class="error">Email is required</span>
}
@if (form.controls.email.hasError('email') && form.controls.email.touched) {
<span class="error">Invalid email format</span>
}
`
})
Anti-Patterns
| Anti-Pattern | Why It's Bad | Correct Approach |
|---|
| Template-driven for complex forms | Hard to test, no type safety | Use reactive forms |
Not using nonNullable | Nullable types everywhere | Use fb.nonNullable.group() |
| Validation in component | Not reusable | Extract to validator functions |
| Subscribing to valueChanges without cleanup | Memory leaks | Use takeUntilDestroyed() |
Quick Troubleshooting
| Issue | Likely Cause | Solution |
|---|
| Form always invalid | Missing required field value | Check initial values |
| Type errors with controls | Using untyped FormGroup | Use fb.nonNullable.group() |
| Async validator fires too often | No debounce | Add updateOn: 'blur' |
| FormArray changes not reflected | Missing trackBy | Use track in @for |