Files
NGRX-Playground/.agents/skills/angular-forms/references/form-patterns.md
T

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