Skip to main content
Technology & EngineeringAngular378 lines

Angular Forms

Reactive forms, form validation, dynamic forms, and typed form controls in Angular

Quick Summary22 lines
You are an expert in Angular reactive forms for building type-safe, validated, and dynamic form experiences.

## Key Points

2. **Use `getRawValue()` for submission.** It includes values from disabled controls, giving you the complete form state.
3. **Set `updateOn: 'blur'` for async validators.** This prevents firing expensive validation on every keystroke.
4. **Create reusable validator functions.** Keep validators pure and parameterized for easy testing and reuse across forms.
5. **Implement `ControlValueAccessor` for custom controls.** This integrates custom UI components seamlessly with Angular's form system.
6. **Show validation errors only after interaction.** Check `touched` or `dirty` before displaying errors so the form does not appear covered in red on first render.
- **Forgetting to import `ReactiveFormsModule`.** Standalone components must include `ReactiveFormsModule` in their `imports` array for `formGroup`, `formControlName`, etc. to work.
- **Mutating form values directly.** Always use `setValue`, `patchValue`, or `reset`. Direct mutation bypasses validation and change tracking.
- **Using `value` instead of `getRawValue()`.** `form.value` excludes disabled controls. Use `getRawValue()` when you need everything.
- **Not unsubscribing from `valueChanges`.** These observables live as long as the form control. Use `takeUntilDestroyed()` or `toSignal` for automatic cleanup.
- **Cross-field validators on the wrong level.** Cross-field validators belong on the `FormGroup`, not on individual controls. Placing them on a control means they cannot access sibling values.

## Quick Example

```html
<app-star-rating formControlName="rating" />
```
skilldb get angular-skills/Angular FormsFull skill: 378 lines
Paste into your CLAUDE.md or agent config

Reactive Forms — Angular

You are an expert in Angular reactive forms for building type-safe, validated, and dynamic form experiences.

Core Philosophy

Angular's reactive forms are designed around the principle that the form model should be the source of truth, not the DOM. Unlike template-driven forms where the template defines the form structure and Angular infers the model, reactive forms define the model explicitly in TypeScript code. This inversion gives you compile-time type safety, programmatic control over validation and state, and deterministic behavior that is easy to test. Every form interaction — value changes, validation status, dirty/touched state — is observable through a well-typed API.

Typed reactive forms (Angular 14+) are a significant upgrade that should be adopted universally. Using nonNullable controls, FormBuilder.nonNullable.group(), and getRawValue() eliminates the null | undefined ambiguity that plagued earlier Angular forms. The type system tells you exactly what shape the form value has at every point, catching binding errors at compile time rather than runtime. This is not an incremental improvement — it fundamentally changes how confidently you can work with form data.

Forms are user-facing surfaces where validation UX directly impacts conversion rates. The framework provides the machinery — synchronous validators, async validators, cross-field validators, ControlValueAccessor for custom controls — but the developer is responsible for the experience. Show errors only after the user has interacted (touched or dirty), use updateOn: 'blur' for expensive async validators, and always preserve user input on validation failure. The best form is one the user does not notice.

Anti-Patterns

  • Mutating Form Values Directly — setting properties on the form value object instead of using setValue, patchValue, or reset. Direct mutation bypasses validation, change tracking, and valueChanges emissions.

  • Using form.value Instead of getRawValue() for Submission — submitting with form.value which excludes disabled controls. Use getRawValue() to capture the complete form state including disabled fields like read-only IDs or computed values.

  • Cross-Field Validators on Individual Controls — placing a password-match validator on the confirmPassword control instead of on the FormGroup. The control-level validator cannot access its sibling password value, causing the validation to fail silently.

  • Showing Errors on Pristine Forms — displaying validation errors immediately on page load before the user has interacted with any field. This creates a hostile first impression. Gate error display on touched or dirty status.

  • Not Unsubscribing from valueChanges — subscribing to form.valueChanges in a component without cleanup, creating subscriptions that persist for the life of the form control. Use takeUntilDestroyed() or toSignal for automatic cleanup.

Overview

Angular provides two approaches to forms: template-driven and reactive. Reactive forms offer explicit control over the form model, strong typing (since Angular 14), and better testability. They are the recommended approach for complex forms with validation, dynamic fields, and cross-field logic.

Core Concepts

Typed FormControl, FormGroup, and FormArray

Angular 14+ introduced strictly typed reactive forms:

import { FormControl, FormGroup, FormArray, Validators } from '@angular/forms';

// Typed FormGroup
const profileForm = new FormGroup({
  firstName: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
  lastName: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
  email: new FormControl('', {
    nonNullable: true,
    validators: [Validators.required, Validators.email],
  }),
  addresses: new FormArray([
    new FormGroup({
      street: new FormControl('', { nonNullable: true }),
      city: new FormControl('', { nonNullable: true }),
      zip: new FormControl('', { nonNullable: true }),
    }),
  ]),
});

// Type is inferred:
// profileForm.value.firstName -> string
// profileForm.controls.email -> FormControl<string>

FormBuilder with Typed API

import { FormBuilder, Validators } from '@angular/forms';

@Component({ /* ... */ })
export class RegistrationComponent {
  private fb = inject(FormBuilder);

  form = this.fb.nonNullable.group({
    username: ['', [Validators.required, Validators.minLength(3)]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required],
    acceptTerms: [false, Validators.requiredTrue],
  });

  onSubmit(): void {
    if (this.form.valid) {
      const value = this.form.getRawValue(); // Fully typed, no null/undefined
      console.log(value.username); // string
    }
  }
}

Template Binding

@Component({
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <label>
        Username
        <input formControlName="username" />
        @if (form.controls.username.hasError('required') &&
             form.controls.username.touched) {
          <span class="error">Username is required</span>
        }
      </label>

      <label>
        Email
        <input formControlName="email" type="email" />
        @if (form.controls.email.hasError('email') &&
             form.controls.email.dirty) {
          <span class="error">Invalid email format</span>
        }
      </label>

      <button type="submit" [disabled]="form.invalid">Register</button>
    </form>
  `,
})
export class RegistrationComponent { /* ... */ }

Implementation Patterns

Custom Validators

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Synchronous validator
export function passwordStrengthValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value as string;
    if (!value) return null;

    const hasUpper = /[A-Z]/.test(value);
    const hasLower = /[a-z]/.test(value);
    const hasDigit = /\d/.test(value);
    const hasSpecial = /[!@#$%^&*]/.test(value);

    const strong = hasUpper && hasLower && hasDigit && hasSpecial;
    return strong ? null : { passwordStrength: {
      missing: [
        ...(!hasUpper ? ['uppercase'] : []),
        ...(!hasLower ? ['lowercase'] : []),
        ...(!hasDigit ? ['digit'] : []),
        ...(!hasSpecial ? ['special character'] : []),
      ],
    }};
  };
}

// Cross-field validator (applied to FormGroup)
export function passwordMatchValidator(): 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.nonNullable.group(
  {
    password: ['', [Validators.required, passwordStrengthValidator()]],
    confirmPassword: ['', Validators.required],
  },
  { validators: [passwordMatchValidator()] }
);

Async Validators

import { AsyncValidatorFn } from '@angular/forms';
import { map, debounceTime, switchMap, first } from 'rxjs';

export function uniqueUsernameValidator(userService: UserService): AsyncValidatorFn {
  return (control: AbstractControl) => {
    return control.valueChanges.pipe(
      debounceTime(400),
      switchMap(value => userService.checkUsername(value)),
      map(taken => (taken ? { usernameTaken: true } : null)),
      first(),
    );
  };
}
username: new FormControl('', {
  nonNullable: true,
  validators: [Validators.required],
  asyncValidators: [uniqueUsernameValidator(inject(UserService))],
  updateOn: 'blur', // Only validate on blur to reduce API calls
}),

Dynamic FormArray

@Component({
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="orderForm">
      <div formArrayName="items">
        @for (item of items.controls; track item; let i = $index) {
          <div [formGroupName]="i" class="item-row">
            <input formControlName="name" placeholder="Item name" />
            <input formControlName="quantity" type="number" />
            <input formControlName="price" type="number" step="0.01" />
            <button type="button" (click)="removeItem(i)">Remove</button>
          </div>
        }
      </div>
      <button type="button" (click)="addItem()">Add Item</button>
      <p>Total: {{ total() | currency }}</p>
    </form>
  `,
})
export class OrderFormComponent {
  private fb = inject(FormBuilder);

  orderForm = this.fb.nonNullable.group({
    items: this.fb.array<FormGroup>([]),
  });

  get items(): FormArray {
    return this.orderForm.controls.items;
  }

  total = toSignal(
    this.orderForm.valueChanges.pipe(
      map(val => (val.items ?? []).reduce(
        (sum, item) => sum + (item?.quantity ?? 0) * (item?.price ?? 0), 0
      ))
    ),
    { initialValue: 0 }
  );

  addItem(): void {
    this.items.push(
      this.fb.nonNullable.group({
        name: ['', Validators.required],
        quantity: [1, [Validators.required, Validators.min(1)]],
        price: [0, [Validators.required, Validators.min(0)]],
      })
    );
  }

  removeItem(index: number): void {
    this.items.removeAt(index);
  }
}

ControlValueAccessor for Custom Form Controls

import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-star-rating',
  standalone: true,
  template: `
    @for (star of stars; track star) {
      <button
        type="button"
        (click)="select(star)"
        [class.active]="star <= value"
        [attr.aria-label]="star + ' star' + (star > 1 ? 's' : '')"
      >★</button>
    }
  `,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: StarRatingComponent,
      multi: true,
    },
  ],
})
export class StarRatingComponent implements ControlValueAccessor {
  stars = [1, 2, 3, 4, 5];
  value = 0;
  disabled = false;

  private onChange: (value: number) => void = () => {};
  private onTouched: () => void = () => {};

  writeValue(value: number): void {
    this.value = value ?? 0;
  }

  registerOnChange(fn: (value: number) => void): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  setDisabledState(disabled: boolean): void {
    this.disabled = disabled;
  }

  select(star: number): void {
    if (!this.disabled) {
      this.value = star;
      this.onChange(star);
      this.onTouched();
    }
  }
}

Use it with formControlName like any built-in control:

<app-star-rating formControlName="rating" />

Error Message Helper

@Component({
  selector: 'app-field-error',
  standalone: true,
  template: `
    @if (control?.invalid && (control?.dirty || control?.touched)) {
      <div class="error-messages">
        @if (control?.hasError('required')) { <p>This field is required.</p> }
        @if (control?.hasError('minlength')) {
          <p>Minimum length: {{ control?.getError('minlength').requiredLength }}</p>
        }
        @if (control?.hasError('email')) { <p>Invalid email address.</p> }
        @if (control?.hasError('passwordStrength')) {
          <p>Missing: {{ control?.getError('passwordStrength').missing.join(', ') }}</p>
        }
      </div>
    }
  `,
})
export class FieldErrorComponent {
  control = input<AbstractControl | null>();
}

Best Practices

  1. Use nonNullable forms. fb.nonNullable.group(...) or new FormControl('', { nonNullable: true }) ensures reset() returns to the initial value instead of null, and improves type inference.

  2. Use getRawValue() for submission. It includes values from disabled controls, giving you the complete form state.

  3. Set updateOn: 'blur' for async validators. This prevents firing expensive validation on every keystroke.

  4. Create reusable validator functions. Keep validators pure and parameterized for easy testing and reuse across forms.

  5. Implement ControlValueAccessor for custom controls. This integrates custom UI components seamlessly with Angular's form system.

  6. Show validation errors only after interaction. Check touched or dirty before displaying errors so the form does not appear covered in red on first render.

Common Pitfalls

  • Forgetting to import ReactiveFormsModule. Standalone components must include ReactiveFormsModule in their imports array for formGroup, formControlName, etc. to work.

  • Mutating form values directly. Always use setValue, patchValue, or reset. Direct mutation bypasses validation and change tracking.

  • Using value instead of getRawValue(). form.value excludes disabled controls. Use getRawValue() when you need everything.

  • Not unsubscribing from valueChanges. These observables live as long as the form control. Use takeUntilDestroyed() or toSignal for automatic cleanup.

  • Cross-field validators on the wrong level. Cross-field validators belong on the FormGroup, not on individual controls. Placing them on a control means they cannot access sibling values.

Install this skill directly: skilldb add angular-skills

Get CLI access →