feat: add Angular NgRx best practices documentation

This commit is contained in:
Dennis Hundertmark
2026-03-08 08:51:02 +01:00
parent 67dc823270
commit 2184971175
47 changed files with 8490 additions and 0 deletions
+441
View File
@@ -0,0 +1,441 @@
---
name: angular-forms
description: Build signal-based forms in Angular v21+ using the new Signal Forms API. Use for form creation with automatic two-way binding, schema-based validation, field state management, and dynamic forms. Triggers on form implementation, adding validation, creating multi-step forms, or building forms with conditional fields. Signal Forms are experimental but recommended for new Angular projects. Don't use for template-driven forms without signals or third-party form libraries like Formly or ngx-formly.
---
# Angular Signal Forms
Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms provide automatic two-way binding, schema-based validation, and reactive field state.
**Note:** Signal Forms are experimental in Angular v21. For production apps requiring stability, see [references/form-patterns.md](references/form-patterns.md) for Reactive Forms patterns.
## Basic Setup
```typescript
import { Component, signal } from "@angular/core";
import { form, FormField, required, email } from "@angular/forms/signals";
interface LoginData {
email: string;
password: string;
}
@Component({
selector: "app-login",
imports: [FormField],
template: `
<form (submit)="onSubmit($event)">
<label>
Email
<input type="email" [formField]="loginForm.email" />
</label>
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
}
<label>
Password
<input type="password" [formField]="loginForm.password" />
</label>
@if (loginForm.password().touched() && loginForm.password().invalid()) {
<p class="error">{{ loginForm.password().errors()[0].message }}</p>
}
<button type="submit" [disabled]="loginForm().invalid()">Login</button>
</form>
`,
})
export class Login {
// Form model - a writable signal
loginModel = signal<LoginData>({
email: "",
password: "",
});
// Create form with validation schema
loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email, { message: "Email is required" });
email(schemaPath.email, { message: "Enter a valid email address" });
required(schemaPath.password, { message: "Password is required" });
});
onSubmit(event: Event) {
event.preventDefault();
if (this.loginForm().valid()) {
const credentials = this.loginModel();
console.log("Submitting:", credentials);
}
}
}
```
## Form Models
Form models are writable signals that serve as the single source of truth:
```typescript
// Define interface for type safety
interface UserProfile {
name: string;
email: string;
age: number | null;
preferences: {
newsletter: boolean;
theme: "light" | "dark";
};
}
// Create model signal with initial values
const userModel = signal<UserProfile>({
name: "",
email: "",
age: null,
preferences: {
newsletter: false,
theme: "light",
},
});
// Create form from model
const userForm = form(userModel);
// Access nested fields via dot notation
userForm.name; // FieldTree<string>
userForm.preferences.theme; // FieldTree<'light' | 'dark'>
```
### Reading Values
```typescript
// Read entire model
const data = this.userModel();
// Read field value via field state
const name = this.userForm.name().value();
const theme = this.userForm.preferences.theme().value();
```
### Updating Values
```typescript
// Replace entire model
this.userModel.set({
name: "Alice",
email: "alice@example.com",
age: 30,
preferences: { newsletter: true, theme: "dark" },
});
// Update single field
this.userForm.name().value.set("Bob");
this.userForm.age().value.update((age) => (age ?? 0) + 1);
```
## Field State
Each field provides reactive signals for validation, interaction, and availability:
```typescript
const emailField = this.form.email();
// Validation state
emailField.valid(); // true if passes all validation
emailField.invalid(); // true if has validation errors
emailField.errors(); // array of error objects
emailField.pending(); // true if async validation in progress
// Interaction state
emailField.touched(); // true after focus + blur
emailField.dirty(); // true after user modification
// Availability state
emailField.disabled(); // true if field is disabled
emailField.hidden(); // true if field should be hidden
emailField.readonly(); // true if field is readonly
// Value
emailField.value(); // current field value (signal)
```
### Form-Level State
The form itself is also a field with aggregated state:
```typescript
// Form is valid when all interactive fields are valid
this.form().valid();
// Form is touched when any field is touched
this.form().touched();
// Form is dirty when any field is modified
this.form().dirty();
```
## Validation
### Built-in Validators
```typescript
import {
form,
required,
email,
min,
max,
minLength,
maxLength,
pattern,
} from "@angular/forms/signals";
const userForm = form(this.userModel, (schemaPath) => {
// Required field
required(schemaPath.name, { message: "Name is required" });
// Email format
email(schemaPath.email, { message: "Invalid email" });
// Numeric range
min(schemaPath.age, 18, { message: "Must be 18+" });
max(schemaPath.age, 120, { message: "Invalid age" });
// String/array length
minLength(schemaPath.password, 8, { message: "Min 8 characters" });
maxLength(schemaPath.bio, 500, { message: "Max 500 characters" });
// Regex pattern
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
message: "Format: 555-123-4567",
});
});
```
### Conditional Validation
```typescript
const orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.promoCode, {
message: "Promo code required for discounts",
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
});
});
```
### Custom Validators
```typescript
import { validate } from "@angular/forms/signals";
const signupForm = form(this.signupModel, (schemaPath) => {
// Custom validation logic
validate(schemaPath.username, ({ value }) => {
if (value().includes(" ")) {
return { kind: "noSpaces", message: "Username cannot contain spaces" };
}
return null;
});
});
```
### Cross-Field Validation
```typescript
const passwordForm = form(this.passwordModel, (schemaPath) => {
required(schemaPath.password);
required(schemaPath.confirmPassword);
// Compare fields
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(schemaPath.password)) {
return { kind: "mismatch", message: "Passwords do not match" };
}
return null;
});
});
```
### Async Validation
```typescript
import { validateHttp } from "@angular/forms/signals";
const signupForm = form(this.signupModel, (schemaPath) => {
validateHttp(schemaPath.username, {
request: ({ value }) => `/api/check-username?u=${value()}`,
onSuccess: (response: { taken: boolean }) => {
if (response.taken) {
return { kind: "taken", message: "Username already taken" };
}
return null;
},
onError: () => ({
kind: "networkError",
message: "Could not verify username",
}),
});
});
```
## Conditional Fields
### Hidden Fields
```typescript
import { hidden } from "@angular/forms/signals";
const profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
});
```
```html
@if (!profileForm.publicUrl().hidden()) {
<input [formField]="profileForm.publicUrl" />
}
```
### Disabled Fields
```typescript
import { disabled } from "@angular/forms/signals";
const orderForm = form(this.orderModel, (schemaPath) => {
disabled(
schemaPath.couponCode,
({ valueOf }) => valueOf(schemaPath.total) < 50,
);
});
```
### Readonly Fields
```typescript
import { readonly } from "@angular/forms/signals";
const accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username); // Always readonly
});
```
## Form Submission
```typescript
import { submit } from "@angular/forms/signals";
@Component({
template: `
<form (submit)="onSubmit($event)">
<input [formField]="form.email" />
<input [formField]="form.password" />
<button type="submit" [disabled]="form().invalid()">Submit</button>
</form>
`,
})
export class Login {
model = signal({ email: "", password: "" });
form = form(this.model, (schemaPath) => {
required(schemaPath.email);
required(schemaPath.password);
});
onSubmit(event: Event) {
event.preventDefault();
// submit() marks all fields touched and runs callback if valid
submit(this.form, async () => {
await this.authService.login(this.model());
});
}
}
```
## Arrays and Dynamic Fields
```typescript
interface Order {
items: Array<{ product: string; quantity: number }>;
}
@Component({
template: `
@for (item of orderForm.items; track $index; let i = $index) {
<div>
<input [formField]="item.product" placeholder="Product" />
<input [formField]="item.quantity" type="number" />
<button type="button" (click)="removeItem(i)">Remove</button>
</div>
}
<button type="button" (click)="addItem()">Add Item</button>
`,
})
export class Order {
orderModel = signal<Order>({
items: [{ product: "", quantity: 1 }],
});
orderForm = form(this.orderModel, (schemaPath) => {
applyEach(schemaPath.items, (item) => {
required(item.product, { message: "Product required" });
min(item.quantity, 1, { message: "Min quantity is 1" });
});
});
addItem() {
this.orderModel.update((m) => ({
...m,
items: [...m.items, { product: "", quantity: 1 }],
}));
}
removeItem(index: number) {
this.orderModel.update((m) => ({
...m,
items: m.items.filter((_, i) => i !== index),
}));
}
}
```
## Displaying Errors
```html
<input [formField]="form.email" />
@if (form.email().touched() && form.email().invalid()) {
<ul class="errors">
@for (error of form.email().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
} @if (form.email().pending()) {
<span>Validating...</span>
}
```
## Styling Based on State
```html
<input
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()"
/>
```
## Reset Form
```typescript
async onSubmit() {
if (!this.form().valid()) return;
await this.api.submit(this.model());
// Clear interaction state
this.form().reset();
// Clear values
this.model.set({ email: '', password: '' });
}
```
For Reactive Forms patterns (production-stable), see [references/form-patterns.md](references/form-patterns.md).
@@ -0,0 +1,413 @@
# Angular Form Patterns
## Table of Contents
- [Reactive Forms (Production-Stable)](#reactive-forms-production-stable)
- [Typed Reactive Forms](#typed-reactive-forms)
- [FormBuilder Patterns](#formbuilder-patterns)
- [Dynamic Forms with FormArray](#dynamic-forms-with-formarray)
- [Custom Validators](#custom-validators)
- [Form State Management](#form-state-management)
## Reactive Forms (Production-Stable)
For production applications requiring stability guarantees, use Reactive Forms:
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
@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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
// 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
```typescript
// 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+)
```typescript
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
```typescript
@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
```typescript
@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;
}
}
}
```
@@ -0,0 +1,107 @@
# Angular Signal Forms - ( FormValueControl )
## Table of Contents
- [Signal Form FormValueControl](#formValueControl)
## Signal Forms FormValueControl
```typescript
interface Rating {
rating: number;
}
import {
form,
FormField,
FormValueControl,
ValidationError,
WithOptionalField,
} from "@angular/forms/signals";
import { MatIconModule } from "@angular/material/icon";
import { MatError } from "@angular/material/form-field";
@Component({
selector: "app-rating",
imports: [MatIconModule, MatError],
template: `
<div class="star-rating-container">
@for (star of starArray(); track $index) {
<mat-icon
(click)="rate(star)"
class="star-icon"
[class.readonly]="readonly()"
[class.error]="invalid()"
[class]="{ filled: star <= value() }"
>
{{ getStarIcon(star) }}
</mat-icon>
}
@if (errors().at(0)?.message) {
<mat-error>
{{ errors().at(0)?.message }}
</mat-error>
}
</div>
`,
styles: ``,
})
export class Rating implements FormValueControl<number> {
// Required: The value of the control, exposed as a two-way binding.
readonly value = model<number>(0);
// Optional: Bindings for other form control states.
readonly readonly = input<boolean>(false);
readonly invalid = input<boolean>(false);
readonly errors: InputSignal<readonly WithOptionalField<ValidationError>[]> =
input<readonly WithOptionalField<ValidationError>[]>([]);
starArray: Signal<number[]> = signal(
Array(5)
.fill(0)
.map((_, i) => i + 1),
);
getStarIcon(index: number): string {
const floorRating = Math.floor(this.value());
if (index <= floorRating) {
return "star"; // Full star
} else {
return "star_border"; // Empty star
}
}
rate(index: number): void {
if (!this.readonly()) {
this.value.set(index);
}
}
}
import { FormField } from "@angular/forms/signals";
@Component({
selector: "app-signal-forms",
imports: [FormField, Rating],
template: `
<form autocomplete="off" (submit)="submit($event)">
<div class="form-field">
<app-rating [formField]="ratingForm.rating"> </app-rating>
<!-- print to show the value updation -->
{{ ratingForm.rating().value() }}
</div>
</form>
`,
styles: ``,
})
export class SignalForms {
readonly ratingModel = signal<Rating>({
rating: 0,
});
readonly ratingForm = form(this.ratingModel);
submit(event: Event): void {
event.preventDefault();
console.log(this.ratingForm.rating().value());
}
}
```