Angular Forms
Reactive forms, form validation, dynamic forms, and typed form controls in Angular
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 linesReactive 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, orreset. Direct mutation bypasses validation, change tracking, andvalueChangesemissions. -
Using
form.valueInstead ofgetRawValue()for Submission — submitting withform.valuewhich excludes disabled controls. UsegetRawValue()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
confirmPasswordcontrol instead of on theFormGroup. The control-level validator cannot access its siblingpasswordvalue, 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
touchedordirtystatus. -
Not Unsubscribing from
valueChanges— subscribing toform.valueChangesin a component without cleanup, creating subscriptions that persist for the life of the form control. UsetakeUntilDestroyed()ortoSignalfor 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
-
Use
nonNullableforms.fb.nonNullable.group(...)ornew FormControl('', { nonNullable: true })ensuresreset()returns to the initial value instead ofnull, and improves type inference. -
Use
getRawValue()for submission. It includes values from disabled controls, giving you the complete form state. -
Set
updateOn: 'blur'for async validators. This prevents firing expensive validation on every keystroke. -
Create reusable validator functions. Keep validators pure and parameterized for easy testing and reuse across forms.
-
Implement
ControlValueAccessorfor custom controls. This integrates custom UI components seamlessly with Angular's form system. -
Show validation errors only after interaction. Check
touchedordirtybefore displaying errors so the form does not appear covered in red on first render.
Common Pitfalls
-
Forgetting to import
ReactiveFormsModule. Standalone components must includeReactiveFormsModulein theirimportsarray forformGroup,formControlName, etc. to work. -
Mutating form values directly. Always use
setValue,patchValue, orreset. Direct mutation bypasses validation and change tracking. -
Using
valueinstead ofgetRawValue().form.valueexcludes disabled controls. UsegetRawValue()when you need everything. -
Not unsubscribing from
valueChanges. These observables live as long as the form control. UsetakeUntilDestroyed()ortoSignalfor 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
Related Skills
Angular Testing
Testing Angular applications with Jest for unit tests and Cypress for end-to-end tests
Angular Dependency Injection
Angular dependency injection system including providers, injection tokens, and hierarchical injectors
Angular Ngrx
NgRx state management with store, effects, selectors, and the component store
Angular Routing
Angular Router configuration including lazy loading, guards, resolvers, and nested routes
Angular Rxjs Patterns
RxJS reactive patterns for data fetching, state management, and event handling in Angular
Angular Signals
Angular Signals for fine-grained reactivity and efficient change detection