--- name: sdlc-angular-forms description: | Angular forms: Reactive Forms (preferred — typed FormGroup/FormControl since Angular 14, FormBuilder, custom + async validators, FormArray, multi-step) and Template-driven (`[(ngModel)]` + FormsModule). Validation strategies, server error mapping, accessibility. Use this skill to: - Build Reactive Forms with typed FormGroup/FormControl. - Use FormBuilder для concise syntax. - Implement custom synchronous and async validators. - Wire FormArray for dynamic field lists. - Map server errors back to form fields. - Pick Reactive vs Template-driven (prefer Reactive). Do NOT use this skill for: - General conventions (see angular-conventions). - State management beyond forms (see angular-state-and-rx). - Routing (see angular-routing). - Testing forms (see angular-testing). paths: ["src/**/*.ts", "src/**/*.html"] --- # Angular Forms Two paradigms: **Reactive Forms** (preferred for non-trivial) and **Template-driven** (`[(ngModel)]`-based, simpler for tiny forms). Pick what the project uses; default to Reactive for new code. ## Detection | Marker (in template imports / `*.module.ts` imports) | Approach | |---|---| | `ReactiveFormsModule` | Reactive Forms (preferred) | | `FormsModule` (without `ReactiveFormsModule`) | Template-driven only | | Both | Mixed; mirror existing style per area | ## Reactive Forms (preferred) ### Basic typed form (Angular 14+) ```ts import { Component, inject } from '@angular/core'; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; @Component({ selector: 'app-login', standalone: true, imports: [ReactiveFormsModule], template: `
`, }) export class LoginComponent { loginForm = new FormGroup({ email: new FormControl('', { validators: [Validators.required, Validators.email], nonNullable: true, }), password: new FormControl('', { validators: [Validators.required, Validators.minLength(8)], nonNullable: true, }), }); submitting = signal(false); get emailControl() { return this.loginForm.controls.email; } get passwordControl() { return this.loginForm.controls.password; } async onSubmit() { if (this.loginForm.invalid) return; this.submitting.set(true); try { const { email, password } = this.loginForm.getRawValue(); // typed: { email: string; password: string } await this.authService.login({ email, password }); this.router.navigate(['/dashboard']); } catch (err) { this.loginForm.setErrors({ serverError: 'Login failed' }); } finally { this.submitting.set(false); } } } ``` ### Why typed forms (Angular 14+) ```ts const form = new FormGroup({ email: new FormControl('', { nonNullable: true }), // FormControlPasswords do not match
} ``` ### Async validators ```ts import { AsyncValidatorFn, AbstractControl } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; import { map, catchError, of } from 'rxjs'; function uniqueEmail(http: HttpClient): AsyncValidatorFn { return (control: AbstractControl) => http.get<{ available: boolean }>(`/api/check?email=${control.value}`).pipe( map((r) => (r.available ? null : { taken: true })), catchError(() => of(null)) // network error — don't block ); } // Apply new FormControl('', { validators: [Validators.required, Validators.email], asyncValidators: [uniqueEmail(this.http)], updateOn: 'blur', // validate on blur — appropriate for expensive checks }); ``` `updateOn: 'blur'` debounces the validation — only fires when user leaves the field. Critical for async validators to avoid hammering the server. ### FormArray (dynamic field lists) ```ts import { FormArray, FormBuilder, Validators } from '@angular/forms'; private fb = inject(FormBuilder); contactsForm = this.fb.nonNullable.group({ contacts: this.fb.array{{ msg }}
} @if (loginForm.errors?.['serverError']; as msg) {{{ msg }}
} ``` ### Multi-step forms Two patterns: **A. Single FormGroup, conditional UI**: ```ts @Component({...}) export class WizardComponent { step = signal(0); form = this.fb.nonNullable.group({ profile: this.fb.nonNullable.group({ name: ['', Validators.required], email: ['', [Validators.required, Validators.email]], }), address: this.fb.nonNullable.group({ street: ['', Validators.required], city: ['', Validators.required], }), }); next() { const currentStep = this.step() === 0 ? this.form.controls.profile : this.form.controls.address; currentStep.markAllAsTouched(); if (currentStep.valid) this.step.update((s) => s + 1); } } ``` **B. Separate forms per step + parent state holder** (signals or service): each step has its own form, parent merges. Pick A for short wizards (≤3 steps), B for longer or when steps reorder dynamically. ## Template-driven Forms (legacy / simple cases) ```ts import { FormsModule } from '@angular/forms'; @Component({ selector: 'app-login', standalone: true, imports: [FormsModule], template: ` `, }) export class LoginComponent { email = ''; password = ''; onSubmit(form: NgForm) { if (form.invalid) return; this.authService.login({ email: this.email, password: this.password }); } } ``` Easier for tiny forms (1-3 fields). Harder to test, harder to type, harder to handle async validation. Don't use for non-trivial forms. Mixing Reactive + Template-driven in the SAME form is unsupported — pick one per form. ## Validation timing (`updateOn`) ```ts new FormControl('', { validators: [Validators.required], updateOn: 'change' | 'blur' | 'submit', }); // Or per-FormGroup: new FormGroup({...}, { updateOn: 'blur' }); ``` | Mode | When | |---|---| | `'change'` (default) | Every keystroke | | `'blur'` | When field loses focus | | `'submit'` | Only on form submission | Use `'blur'` for async validators (avoid hammering server). Use `'submit'` rarely (delays user feedback). ## Accessibility checklist - Every input has `