Module Overview
Estimated Time: 4-5 hours | Difficulty: Intermediate | Prerequisites: Module 6
- Template-driven forms basics
- Reactive forms deep dive
- Built-in and custom validators
- Dynamic forms and FormArrays
- Form state management
- Accessibility best practices
Forms Comparison
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Template-Driven vs Reactive Forms │
├─────────────────────────────┬───────────────────────────────────────────┤
│ Template-Driven │ Reactive Forms │
├─────────────────────────────┼───────────────────────────────────────────┤
│ • Logic in template │ • Logic in component class │
│ • Uses ngModel directive │ • Uses FormControl, FormGroup │
│ • Two-way data binding │ • Explicit data flow │
│ • Less boilerplate │ • More testable │
│ • Good for simple forms │ • Good for complex forms │
│ • Async validation tricky │ • Easy async validation │
│ │ • Dynamic form generation │
│ │ • Better type safety │
└─────────────────────────────┴───────────────────────────────────────────┘
Template-Driven Forms
Basic Setup
Copy
// Import FormsModule for template-driven forms
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [FormsModule],
template: `
<form #contactForm="ngForm" (ngSubmit)="onSubmit(contactForm)">
<div class="form-group">
<label for="name">Name</label>
<input
type="text"
id="name"
name="name"
[(ngModel)]="model.name"
required
minlength="2"
#name="ngModel"
/>
@if (name.invalid && name.touched) {
<div class="errors">
@if (name.errors?.['required']) {
<span>Name is required</span>
}
@if (name.errors?.['minlength']) {
<span>Name must be at least 2 characters</span>
}
</div>
}
</div>
<div class="form-group">
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
[(ngModel)]="model.email"
required
email
#email="ngModel"
/>
@if (email.invalid && email.touched) {
<div class="errors">
@if (email.errors?.['required']) {
<span>Email is required</span>
}
@if (email.errors?.['email']) {
<span>Invalid email format</span>
}
</div>
}
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea
id="message"
name="message"
[(ngModel)]="model.message"
required
rows="4"
></textarea>
</div>
<button type="submit" [disabled]="contactForm.invalid">
Send Message
</button>
<pre>Form valid: {{ contactForm.valid }}</pre>
<pre>Model: {{ model | json }}</pre>
</form>
`
})
export class ContactFormComponent {
model = {
name: '',
email: '',
message: ''
};
onSubmit(form: NgForm) {
if (form.valid) {
console.log('Form submitted:', this.model);
form.reset();
}
}
}
Reactive Forms
Setup and Basic Usage
Copy
import { Component } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-signup-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
type="text"
formControlName="username"
/>
@if (signupForm.get('username')?.invalid && signupForm.get('username')?.touched) {
<div class="errors">
@if (signupForm.get('username')?.errors?.['required']) {
<span>Username is required</span>
}
@if (signupForm.get('username')?.errors?.['minlength']) {
<span>Username must be at least 3 characters</span>
}
</div>
}
</div>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
formControlName="password"
/>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
formControlName="confirmPassword"
/>
@if (signupForm.errors?.['passwordMismatch']) {
<span class="error">Passwords must match</span>
}
</div>
<button type="submit" [disabled]="signupForm.invalid">
Sign Up
</button>
</form>
`
})
export class SignupFormComponent {
private fb = inject(FormBuilder);
signupForm = this.fb.group({
username: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required]
}, {
validators: this.passwordMatchValidator
});
passwordMatchValidator(group: FormGroup) {
const password = group.get('password')?.value;
const confirmPassword = group.get('confirmPassword')?.value;
return password === confirmPassword ? null : { passwordMismatch: true };
}
onSubmit() {
if (this.signupForm.valid) {
console.log('Form data:', this.signupForm.value);
}
}
}
FormControl, FormGroup, FormArray
Copy
import { FormControl, FormGroup, FormArray, Validators } from '@angular/forms';
// FormControl - single input
const nameControl = new FormControl('', [Validators.required]);
console.log(nameControl.value); // ''
console.log(nameControl.valid); // false
console.log(nameControl.errors); // { required: true }
// FormGroup - group of controls
const userForm = new FormGroup({
name: new FormControl('', Validators.required),
email: new FormControl('', [Validators.required, Validators.email]),
address: new FormGroup({ // Nested group
street: new FormControl(''),
city: new FormControl(''),
zip: new FormControl('')
})
});
// Access nested controls
userForm.get('address.city')?.setValue('New York');
// FormArray - dynamic array of controls
const skills = new FormArray([
new FormControl('TypeScript'),
new FormControl('Angular')
]);
// Add/remove items dynamically
skills.push(new FormControl('RxJS'));
skills.removeAt(0);
FormBuilder
TheFormBuilder provides a cleaner API:
Copy
@Component({...})
export class ProfileFormComponent {
private fb = inject(FormBuilder);
profileForm = this.fb.group({
// Shorthand: ['initialValue', validators]
firstName: ['', Validators.required],
lastName: ['', Validators.required],
// With async validators
email: ['', [Validators.required, Validators.email], [this.emailExistsValidator]],
// Nested FormGroup
address: this.fb.group({
street: [''],
city: [''],
state: [''],
zip: ['', Validators.pattern(/^\d{5}$/)]
}),
// FormArray
phones: this.fb.array([
this.fb.control('')
]),
// With options
newsletter: this.fb.control(false, { updateOn: 'blur' })
});
// FormArray helpers
get phones() {
return this.profileForm.get('phones') as FormArray;
}
addPhone() {
this.phones.push(this.fb.control(''));
}
removePhone(index: number) {
this.phones.removeAt(index);
}
}
Strongly Typed Forms
Angular 14+ provides strongly typed reactive forms:Copy
interface UserFormModel {
name: string;
email: string;
age: number;
preferences: {
newsletter: boolean;
theme: 'light' | 'dark';
};
tags: string[];
}
@Component({...})
export class TypedFormComponent {
private fb = inject(NonNullableFormBuilder);
// Fully typed form
userForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
age: [18, [Validators.min(0), Validators.max(120)]],
preferences: this.fb.group({
newsletter: [false],
theme: ['light' as const]
}),
tags: this.fb.array<string>([])
});
// Type-safe access
updateTheme(theme: 'light' | 'dark') {
this.userForm.controls.preferences.controls.theme.setValue(theme);
}
// getRawValue() returns properly typed object
onSubmit() {
const data = this.userForm.getRawValue();
// data is typed as UserFormModel
console.log(data.name, data.preferences.theme);
}
}
Custom Validators
Synchronous Validators
Copy
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Factory function for configurable validator
export function minAge(min: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const age = control.value;
if (age < min) {
return { minAge: { required: min, actual: age } };
}
return null;
};
}
// Simple validator
export function noWhitespace(control: AbstractControl): ValidationErrors | null {
const hasWhitespace = /\s/.test(control.value);
return hasWhitespace ? { noWhitespace: true } : null;
}
// Cross-field validator (for FormGroup)
export function dateRangeValidator(
startField: string,
endField: string
): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const start = group.get(startField)?.value;
const end = group.get(endField)?.value;
if (start && end && new Date(start) > new Date(end)) {
return { dateRange: { message: 'Start date must be before end date' } };
}
return null;
};
}
// Usage
const form = this.fb.group({
age: [25, [Validators.required, minAge(18)]],
username: ['', [Validators.required, noWhitespace]],
startDate: [''],
endDate: ['']
}, {
validators: dateRangeValidator('startDate', 'endDate')
});
Asynchronous Validators
Copy
import { AsyncValidatorFn } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { map, catchError, switchMap } from 'rxjs/operators';
// Async validator using inject()
export function usernameExistsValidator(): AsyncValidatorFn {
const userService = inject(UserService);
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
// Debounce to avoid too many API calls
return timer(300).pipe(
switchMap(() => userService.checkUsername(control.value)),
map(exists => exists ? { usernameTaken: true } : null),
catchError(() => of(null))
);
};
}
// Usage
const form = this.fb.group({
username: [
'',
[Validators.required],
[usernameExistsValidator()] // Async validators as third argument
]
});
Dynamic Forms with FormArray
Copy
@Component({
selector: 'app-order-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="orderForm" (ngSubmit)="onSubmit()">
<h2>Order Items</h2>
<div formArrayName="items">
@for (item of items.controls; track i; let i = $index) {
<div class="item-row" [formGroupName]="i">
<input formControlName="name" placeholder="Item name" />
<input formControlName="quantity" type="number" min="1" />
<input formControlName="price" type="number" min="0" step="0.01" />
<span class="subtotal">
${{ getSubtotal(i) | number:'1.2-2' }}
</span>
<button type="button" (click)="removeItem(i)">Remove</button>
</div>
}
</div>
<button type="button" (click)="addItem()">Add Item</button>
<div class="summary">
<strong>Total: ${{ total | number:'1.2-2' }}</strong>
</div>
<button type="submit" [disabled]="orderForm.invalid">
Place Order
</button>
</form>
`
})
export class OrderFormComponent {
private fb = inject(FormBuilder);
orderForm = this.fb.group({
customerName: ['', Validators.required],
items: this.fb.array([this.createItemGroup()])
});
get items(): FormArray {
return this.orderForm.get('items') as FormArray;
}
get total(): number {
return this.items.controls.reduce((sum, control, i) => {
return sum + this.getSubtotal(i);
}, 0);
}
createItemGroup(): FormGroup {
return this.fb.group({
name: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
price: [0, [Validators.required, Validators.min(0)]]
});
}
addItem() {
this.items.push(this.createItemGroup());
}
removeItem(index: number) {
this.items.removeAt(index);
}
getSubtotal(index: number): number {
const item = this.items.at(index);
return (item.get('quantity')?.value || 0) * (item.get('price')?.value || 0);
}
onSubmit() {
if (this.orderForm.valid) {
console.log('Order:', this.orderForm.value);
}
}
}
Form State and CSS Classes
Angular automatically applies CSS classes based on form state:Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Form Control States & CSS Classes │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ State │ True Class │ False Class │
│ ──────────────┼─────────────────┼───────────────── │
│ visited │ .ng-touched │ .ng-untouched │
│ changed │ .ng-dirty │ .ng-pristine │
│ valid │ .ng-valid │ .ng-invalid │
│ pending │ .ng-pending │ (none) │
│ │
│ Example CSS: │
│ ───────────── │
│ input.ng-invalid.ng-touched { │
│ border-color: red; │
│ } │
│ │
│ input.ng-valid.ng-touched { │
│ border-color: green; │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Copy
/* forms.scss */
.form-control {
&.ng-invalid.ng-touched {
border-color: #dc3545;
&:focus {
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
}
&.ng-valid.ng-touched {
border-color: #28a745;
}
&.ng-pending {
border-color: #ffc107;
}
}
.error-message {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
Error Handling Component
Create a reusable error display component:Copy
// form-errors.component.ts
@Component({
selector: 'app-form-errors',
standalone: true,
template: `
@if (control && control.invalid && (control.dirty || control.touched)) {
<div class="error-messages" role="alert">
@for (error of errorMessages; track error) {
<span class="error">{{ error }}</span>
}
</div>
}
`,
styles: [`
.error-messages {
color: #dc3545;
font-size: 0.875rem;
}
.error { display: block; }
`]
})
export class FormErrorsComponent {
control = input<AbstractControl | null>(null);
private errorMessages$ = {
required: 'This field is required',
email: 'Please enter a valid email',
minlength: (error: any) => `Minimum ${error.requiredLength} characters required`,
maxlength: (error: any) => `Maximum ${error.requiredLength} characters allowed`,
min: (error: any) => `Minimum value is ${error.min}`,
max: (error: any) => `Maximum value is ${error.max}`,
pattern: 'Invalid format'
};
get errorMessages(): string[] {
const control = this.control();
if (!control || !control.errors) return [];
return Object.keys(control.errors).map(key => {
const errorDef = this.errorMessages$[key];
if (typeof errorDef === 'function') {
return errorDef(control.errors![key]);
}
return errorDef || `Validation error: ${key}`;
});
}
}
// Usage
@Component({
template: `
<input formControlName="email" />
<app-form-errors [control]="form.get('email')" />
`,
imports: [FormErrorsComponent, ReactiveFormsModule]
})
Form Accessibility
Copy
@Component({
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-field">
<label for="email">
Email
<span class="required" aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
formControlName="email"
[attr.aria-invalid]="form.get('email')?.invalid"
[attr.aria-describedby]="form.get('email')?.errors ? 'email-error' : null"
/>
@if (form.get('email')?.invalid && form.get('email')?.touched) {
<div id="email-error" class="error" role="alert" aria-live="polite">
Please enter a valid email address
</div>
}
</div>
<div class="form-field">
<fieldset>
<legend>Notification Preferences</legend>
<label>
<input type="checkbox" formControlName="emailNotifs" />
Email notifications
</label>
<label>
<input type="checkbox" formControlName="smsNotifs" />
SMS notifications
</label>
</fieldset>
</div>
<button
type="submit"
[disabled]="form.invalid"
[attr.aria-disabled]="form.invalid"
>
Submit
</button>
</form>
`
})
export class AccessibleFormComponent {
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
emailNotifs: [true],
smsNotifs: [false]
});
}
Practice Exercise
Exercise: Build a Multi-Step Registration Form
Create a registration wizard with:
- Step 1: Personal info (name, email)
- Step 2: Account details (username, password)
- Step 3: Preferences (newsletter, theme)
- Navigation between steps
- Validation per step
- Summary and submit on final step
Solution
Solution
Copy
interface RegistrationForm {
personal: {
firstName: string;
lastName: string;
email: string;
};
account: {
username: string;
password: string;
confirmPassword: string;
};
preferences: {
newsletter: boolean;
theme: 'light' | 'dark';
notifications: string[];
};
}
@Component({
selector: 'app-registration-wizard',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<div class="wizard">
<div class="steps">
<span [class.active]="currentStep() === 1">1. Personal</span>
<span [class.active]="currentStep() === 2">2. Account</span>
<span [class.active]="currentStep() === 3">3. Preferences</span>
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Step 1: Personal Info -->
@if (currentStep() === 1) {
<div formGroupName="personal">
<h2>Personal Information</h2>
<div class="field">
<label>First Name</label>
<input formControlName="firstName" />
</div>
<div class="field">
<label>Last Name</label>
<input formControlName="lastName" />
</div>
<div class="field">
<label>Email</label>
<input type="email" formControlName="email" />
</div>
</div>
}
<!-- Step 2: Account Details -->
@if (currentStep() === 2) {
<div formGroupName="account">
<h2>Account Details</h2>
<div class="field">
<label>Username</label>
<input formControlName="username" />
</div>
<div class="field">
<label>Password</label>
<input type="password" formControlName="password" />
</div>
<div class="field">
<label>Confirm Password</label>
<input type="password" formControlName="confirmPassword" />
</div>
@if (form.get('account')?.errors?.['passwordMismatch']) {
<span class="error">Passwords don't match</span>
}
</div>
}
<!-- Step 3: Preferences -->
@if (currentStep() === 3) {
<div formGroupName="preferences">
<h2>Preferences</h2>
<label>
<input type="checkbox" formControlName="newsletter" />
Subscribe to newsletter
</label>
<fieldset>
<legend>Theme</legend>
<label>
<input type="radio" formControlName="theme" value="light" />
Light
</label>
<label>
<input type="radio" formControlName="theme" value="dark" />
Dark
</label>
</fieldset>
<h3>Summary</h3>
<pre>{{ form.value | json }}</pre>
</div>
}
<div class="navigation">
@if (currentStep() > 1) {
<button type="button" (click)="prevStep()">Previous</button>
}
@if (currentStep() < 3) {
<button
type="button"
(click)="nextStep()"
[disabled]="!isCurrentStepValid()"
>
Next
</button>
} @else {
<button type="submit" [disabled]="form.invalid">
Complete Registration
</button>
}
</div>
</form>
</div>
`
})
export class RegistrationWizardComponent {
private fb = inject(FormBuilder);
currentStep = signal(1);
form = this.fb.group({
personal: this.fb.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]]
}),
account: this.fb.group({
username: ['', [Validators.required, Validators.minLength(3)]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required]
}, { validators: this.passwordMatchValidator }),
preferences: this.fb.group({
newsletter: [true],
theme: ['light' as const],
notifications: this.fb.array([])
})
});
passwordMatchValidator(group: AbstractControl) {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
}
isCurrentStepValid(): boolean {
const stepGroups = ['personal', 'account', 'preferences'];
const currentGroup = this.form.get(stepGroups[this.currentStep() - 1]);
return currentGroup?.valid ?? false;
}
nextStep() {
if (this.isCurrentStepValid() && this.currentStep() < 3) {
this.currentStep.update(s => s + 1);
}
}
prevStep() {
if (this.currentStep() > 1) {
this.currentStep.update(s => s - 1);
}
}
onSubmit() {
if (this.form.valid) {
console.log('Registration data:', this.form.value);
}
}
}
Summary
1
Template-Driven Forms
Use FormsModule with ngModel for simple forms with two-way binding
2
Reactive Forms
Use FormControl, FormGroup, FormArray for complex, testable forms
3
Validators
Built-in validators plus custom sync/async validators
4
FormBuilder
Cleaner API for building forms with NonNullableFormBuilder for type safety
5
Dynamic Forms
Use FormArray for repeating fields and dynamic form generation
Next Steps
Next: HTTP Client & RxJS
Connect to APIs and master reactive data streams