11 KiB
11 KiB
Angular Form Patterns
Table of Contents
- Reactive Forms (Production-Stable)
- Typed Reactive Forms
- FormBuilder Patterns
- Dynamic Forms with FormArray
- Custom Validators
- Form State Management
Reactive Forms (Production-Stable)
For production applications requiring stability guarantees, use Reactive Forms:
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `
<form
[formGroup]="form"
(ngSubmit)="onSubmit()">
<input formControlName="email" />
@if (form.controls.email.errors?.['required'] && form.controls.email.touched) {
<span class="error">Email is required</span>
}
<input
type="password"
formControlName="password" />
<button
type="submit"
[disabled]="form.invalid">
Login
</button>
</form>
`,
})
export class Login {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
}
}
}
Typed Reactive Forms
Typed FormControl
import { FormControl } from '@angular/forms';
// Inferred type: FormControl<string | null>
const name = new FormControl('');
// Non-nullable (no reset to null)
const email = new FormControl('', { nonNullable: true });
// Type: FormControl<string>
// With validators
const username = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3)],
});
Typed FormGroup
import { FormGroup, FormControl } from '@angular/forms';
interface UserForm {
name: FormControl<string>;
email: FormControl<string>;
age: FormControl<number | null>;
}
const form = new FormGroup<UserForm>({
name: new FormControl('', { nonNullable: true }),
email: new FormControl('', { nonNullable: true }),
age: new FormControl<number | null>(null),
});
// Typed value access
const name: string = form.controls.name.value;
NonNullableFormBuilder
import { inject } from '@angular/core';
import { NonNullableFormBuilder } from '@angular/forms';
@Component({...})
export class Profile {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
name: ['', Validators.required], // FormControl<string>
email: ['', [Validators.required, Validators.email]],
preferences: this.fb.group({
newsletter: [false], // FormControl<boolean>
theme: ['light' as 'light' | 'dark'], // FormControl<'light' | 'dark'>
}),
});
}
FormBuilder Patterns
Nested FormGroups
@Component({
imports: [ReactiveFormsModule],
template: `
<form
[formGroup]="form"
(ngSubmit)="onSubmit()">
<input
formControlName="name"
placeholder="Name" />
<div formGroupName="address">
<input
formControlName="street"
placeholder="Street" />
<input
formControlName="city"
placeholder="City" />
<input
formControlName="zip"
placeholder="ZIP" />
</div>
<button type="submit">Submit</button>
</form>
`,
})
export class Profile {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
name: ['', Validators.required],
address: this.fb.group({
street: [''],
city: ['', Validators.required],
zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
}),
});
}
Dynamic Forms with FormArray
import { FormArray } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input
formControlName="product"
placeholder="Product" />
<input
formControlName="quantity"
type="number" />
<button
type="button"
(click)="removeItem(i)">
Remove
</button>
</div>
}
</div>
<button
type="button"
(click)="addItem()">
Add Item
</button>
</form>
`,
})
export class Order {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
items: this.fb.array([this.createItem()]),
});
get items() {
return this.form.controls.items;
}
createItem() {
return this.fb.group({
product: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
});
}
addItem() {
this.items.push(this.createItem());
}
removeItem(index: number) {
this.items.removeAt(index);
}
}
Custom Validators
Sync Validator
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenValue(forbidden: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return control.value === forbidden
? { forbiddenValue: { value: control.value } }
: null;
};
}
// Usage
name: ['', [Validators.required, forbiddenValue('admin')]],
Cross-Field Validator
export function passwordMatch(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
};
}
// Usage
form = this.fb.group(
{
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
},
{ validators: passwordMatch() },
);
Async Validator
import { AsyncValidatorFn } from '@angular/forms';
import { map, catchError, of } from 'rxjs';
export function uniqueEmail(userService: User): 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], // sync validators
[uniqueEmail(this.userService)] // async validators
],
Form State Management
State Properties
// Check states
form.valid; // All validations pass
form.invalid; // Has validation errors
form.pending; // Async validation in progress
form.dirty; // Value changed by user
form.pristine; // Value not changed
form.touched; // Control has been focused
form.untouched; // Control never focused
// Update values
form.setValue({ name: 'John', email: 'john@example.com' }); // Must include all
form.patchValue({ name: 'John' }); // Partial update
// Reset
form.reset();
form.reset({ name: 'Default' });
// Disable/Enable
form.disable();
form.enable();
form.controls.email.disable();
// Mark states
form.markAllAsTouched(); // Show all errors
form.markAsPristine();
form.markAsDirty();
Value Changes Observable
// Subscribe to value changes
form.valueChanges.subscribe((value) => {
console.log('Form value:', value);
});
// Single control with debounce
form.controls.email.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((email) => {
this.validateEmail(email);
});
// Status changes
form.statusChanges.subscribe((status) => {
console.log('Form status:', status); // VALID, INVALID, PENDING
});
Unified Events (Angular v18+)
import {
ValueChangeEvent,
StatusChangeEvent,
PristineChangeEvent,
TouchedChangeEvent,
FormSubmittedEvent,
FormResetEvent,
} from '@angular/forms';
form.events.subscribe((event) => {
if (event instanceof ValueChangeEvent) {
console.log('Value changed:', event.value);
}
if (event instanceof StatusChangeEvent) {
console.log('Status changed:', event.status);
}
if (event instanceof PristineChangeEvent) {
console.log('Pristine changed:', event.pristine);
}
if (event instanceof TouchedChangeEvent) {
console.log('Touched changed:', event.touched);
}
if (event instanceof FormSubmittedEvent) {
console.log('Form submitted');
}
if (event instanceof FormResetEvent) {
console.log('Form reset');
}
});
Error Display Pattern
@Component({
template: `
<input formControlName="email" />
@if (form.controls.email.invalid && form.controls.email.touched) {
<div class="errors">
@if (form.controls.email.errors?.['required']) {
<span>Email is required</span>
}
@if (form.controls.email.errors?.['email']) {
<span>Invalid email format</span>
}
</div>
}
`,
})
export class Form {
// Helper for cleaner templates
hasError(controlName: string, errorKey: string): boolean {
const control = this.form.get(controlName);
return (control?.hasError(errorKey) && control?.touched) || false;
}
}
Form Submission Pattern
@Component({
template: `
<form
[formGroup]="form"
(ngSubmit)="onSubmit()">
<!-- fields -->
<button
type="submit"
[disabled]="form.invalid || isSubmitting">
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</button>
</form>
`,
})
export class Form {
isSubmitting = false;
async onSubmit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.isSubmitting = true;
try {
await this.api.submit(this.form.getRawValue());
this.form.reset();
} catch (error) {
// Handle error
} finally {
this.isSubmitting = false;
}
}
}