Files
NGRX-Playground/.agents/skills/angular-forms/references/form-patterns.md
T
2026-03-08 08:51:02 +01:00

9.7 KiB

Angular Form Patterns

Table of Contents

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;
    }
  }
}