Skip to main content
Angular Forms

Module Overview

Estimated Time: 4-5 hours | Difficulty: Intermediate | Prerequisites: Module 6
Angular provides two approaches to handling forms: template-driven forms for simple cases and reactive forms for complex scenarios. Both provide validation, error handling, and form state management. What You’ll Learn:
  • 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

┌─────────────────────────────────────────────────────────────────────────┐
│              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

// 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

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

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

The FormBuilder provides a cleaner API:
@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:
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

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

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

@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:
┌─────────────────────────────────────────────────────────────────────────┐
│                    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;                                                 │
│   }                                                                      │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘
/* 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:
// 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

@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:
  1. Step 1: Personal info (name, email)
  2. Step 2: Account details (username, password)
  3. Step 3: Preferences (newsletter, theme)
  4. Navigation between steps
  5. Validation per step
  6. Summary and submit on final step
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