signal-forms
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAngular Signal Forms Guide
Angular Signal Forms 指南
Create type-safe forms using Angular Signal Forms with built-in schema validation.
Note: Signal Forms are experimental in Angular v21+. Use with awareness of potential API changes.
使用Angular Signal Forms创建具备内置Schema验证的类型安全表单。
注意: Signal Forms在Angular v21+中属于实验性功能。使用时请注意API可能会发生变更。
Core Pattern
核心模式
typescript
import { Component, signal, ChangeDetectionStrategy } from "@angular/core";
import {
form,
schema,
Field,
required,
email,
minLength,
} from "@angular/forms/signals";
// 1. Define TypeScript interface
interface User {
name: string;
email: string;
}
// 2. Define validation schema
const userSchema = schema<User>((f) => {
required(f.name, { message: "Name is required" });
minLength(f.name, 3, { message: "Name must be at least 3 characters" });
required(f.email, { message: "Email is required" });
email(f.email, { message: "Enter a valid email address" });
});
// 3. Create component
@Component({
selector: "app-user-form",
imports: [Field],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form (ngSubmit)="onSubmit()">
<input type="text" placeholder="Name" [field]="userForm.name" />
@if (userForm.name().touched() || userForm.name().dirty()) {
@for (error of userForm.name().errors(); track error.kind) {
<p class="error">{{ error.message }}</p>
}
}
<input type="email" placeholder="Email" [field]="userForm.email" />
@if (userForm.email().touched() || userForm.email().dirty()) {
@for (error of userForm.email().errors(); track error.kind) {
<p class="error">{{ error.message }}</p>
}
}
<button type="submit" [disabled]="!userForm().valid()">Submit</button>
</form>
`,
})
export class UserForm {
// Initialize state signal
user = signal<User>({ name: "", email: "" });
// Create form with validation
userForm = form(this.user, userSchema);
onSubmit(): void {
if (this.userForm().valid()) {
console.log("Valid data:", this.user());
}
}
}typescript
import { Component, signal, ChangeDetectionStrategy } from "@angular/core";
import {
form,
schema,
Field,
required,
email,
minLength,
} from "@angular/forms/signals";
// 1. Define TypeScript interface
interface User {
name: string;
email: string;
}
// 2. Define validation schema
const userSchema = schema<User>((f) => {
required(f.name, { message: "Name is required" });
minLength(f.name, 3, { message: "Name must be at least 3 characters" });
required(f.email, { message: "Email is required" });
email(f.email, { message: "Enter a valid email address" });
});
// 3. Create component
@Component({
selector: "app-user-form",
imports: [Field],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form (ngSubmit)="onSubmit()">
<input type="text" placeholder="Name" [field]="userForm.name" />
@if (userForm.name().touched() || userForm.name().dirty()) {
@for (error of userForm.name().errors(); track error.kind) {
<p class="error">{{ error.message }}</p>
}
}
<input type="email" placeholder="Email" [field]="userForm.email" />
@if (userForm.email().touched() || userForm.email().dirty()) {
@for (error of userForm.email().errors(); track error.kind) {
<p class="error">{{ error.message }}</p>
}
}
<button type="submit" [disabled]="!userForm().valid()">Submit</button>
</form>
`,
})
export class UserForm {
// Initialize state signal
user = signal<User>({ name: "", email: "" });
// Create form with validation
userForm = form(this.user, userSchema);
onSubmit(): void {
if (this.userForm().valid()) {
console.log("Valid data:", this.user());
}
}
}Built-in Validators
内置验证器
typescript
import {
schema,
required,
email,
minLength,
maxLength,
min,
max,
pattern,
validate,
customError,
applyEach,
} from "@angular/forms/signals";
const formSchema = schema<FormData>((f) => {
// Required field
required(f.name, { message: "Name is required" });
// Email validation
email(f.email, { message: "Invalid email format" });
// String length
minLength(f.password, 8, {
message: "Password must be at least 8 characters",
});
maxLength(f.bio, 500, { message: "Bio cannot exceed 500 characters" });
// Number range
min(f.age, 18, { message: "Must be at least 18" });
max(f.quantity, 100, { message: "Maximum 100 items" });
// Regex pattern
pattern(f.phone, /^\+?[1-9]\d{1,14}$/, { message: "Invalid phone number" });
pattern(f.zip, /^\d{5}$/, { message: "ZIP must be 5 digits" });
});typescript
import {
schema,
required,
email,
minLength,
maxLength,
min,
max,
pattern,
validate,
customError,
applyEach,
} from "@angular/forms/signals";
const formSchema = schema<FormData>((f) => {
// Required field
required(f.name, { message: "Name is required" });
// Email validation
email(f.email, { message: "Invalid email format" });
// String length
minLength(f.password, 8, {
message: "Password must be at least 8 characters",
});
maxLength(f.bio, 500, { message: "Bio cannot exceed 500 characters" });
// Number range
min(f.age, 18, { message: "Must be at least 18" });
max(f.quantity, 100, { message: "Maximum 100 items" });
// Regex pattern
pattern(f.phone, /^\+?[1-9]\d{1,14}$/, { message: "Invalid phone number" });
pattern(f.zip, /^\d{5}$/, { message: "ZIP must be 5 digits" });
});Custom Validation
自定义验证
typescript
const formSchema = schema<User>((f) => {
required(f.username);
// Custom validation logic
validate(f.username, (field) => {
const value = field.value();
if (value && !/^[a-zA-Z]/.test(value)) {
return customError({
kind: "pattern",
message: "Username must start with a letter",
});
}
return null;
});
// Password strength validation
validate(f.password, (field) => {
const value = field.value();
if (!value) return null;
if (value.length < 8) {
return customError({
kind: "minLength",
message: "At least 8 characters",
});
}
if (!/[A-Z]/.test(value)) {
return customError({
kind: "pattern",
message: "Include an uppercase letter",
});
}
if (!/[0-9]/.test(value)) {
return customError({ kind: "pattern", message: "Include a number" });
}
return null;
});
});typescript
const formSchema = schema<User>((f) => {
required(f.username);
// Custom validation logic
validate(f.username, (field) => {
const value = field.value();
if (value && !/^[a-zA-Z]/.test(value)) {
return customError({
kind: "pattern",
message: "Username must start with a letter",
});
}
return null;
});
// Password strength validation
validate(f.password, (field) => {
const value = field.value();
if (!value) return null;
if (value.length < 8) {
return customError({
kind: "minLength",
message: "At least 8 characters",
});
}
if (!/[A-Z]/.test(value)) {
return customError({
kind: "pattern",
message: "Include an uppercase letter",
});
}
if (!/[0-9]/.test(value)) {
return customError({ kind: "pattern", message: "Include a number" });
}
return null;
});
});Password Confirmation
密码确认
typescript
interface SignupForm {
password: string;
confirmPassword: string;
}
const signupSchema = schema<SignupForm>((f) => {
required(f.password, { message: "Password is required" });
minLength(f.password, 8, { message: "At least 8 characters" });
required(f.confirmPassword, { message: "Please confirm password" });
// Cross-field validation
validate(f.confirmPassword, (field) => {
const password = f.password.value();
const confirm = field.value();
if (confirm && password !== confirm) {
return customError({
kind: "passwordMismatch",
message: "Passwords do not match",
});
}
return null;
});
});typescript
interface SignupForm {
password: string;
confirmPassword: string;
}
const signupSchema = schema<SignupForm>((f) => {
required(f.password, { message: "Password is required" });
minLength(f.password, 8, { message: "At least 8 characters" });
required(f.confirmPassword, { message: "Please confirm password" });
// Cross-field validation
validate(f.confirmPassword, (field) => {
const password = f.password.value();
const confirm = field.value();
if (confirm && password !== confirm) {
return customError({
kind: "passwordMismatch",
message: "Passwords do not match",
});
}
return null;
});
});Nested Objects
嵌套对象
typescript
interface Address {
street: string;
city: string;
zip: string;
}
interface User {
name: string;
address: Address;
}
const userSchema = schema<User>((f) => {
required(f.name, { message: "Name is required" });
// Nested validation
required(f.address.street, { message: "Street is required" });
required(f.address.city, { message: "City is required" });
required(f.address.zip, { message: "ZIP is required" });
pattern(f.address.zip, /^\d{5}$/, { message: "ZIP must be 5 digits" });
});
// Template
`
<input [field]="userForm.name" placeholder="Name" />
<input [field]="userForm.address.street" placeholder="Street" />
<input [field]="userForm.address.city" placeholder="City" />
<input [field]="userForm.address.zip" placeholder="ZIP" />
`;typescript
interface Address {
street: string;
city: string;
zip: string;
}
interface User {
name: string;
address: Address;
}
const userSchema = schema<User>((f) => {
required(f.name, { message: "Name is required" });
// Nested validation
required(f.address.street, { message: "Street is required" });
required(f.address.city, { message: "City is required" });
required(f.address.zip, { message: "ZIP is required" });
pattern(f.address.zip, /^\d{5}$/, { message: "ZIP must be 5 digits" });
});
// Template
`
<input [field]="userForm.name" placeholder="Name" />
<input [field]="userForm.address.street" placeholder="Street" />
<input [field]="userForm.address.city" placeholder="City" />
<input [field]="userForm.address.zip" placeholder="ZIP" />
`;Dynamic Arrays
动态数组
typescript
interface Hobby {
name: string;
years: number;
}
interface User {
name: string;
hobbies: Hobby[];
}
const userSchema = schema<User>((f) => {
required(f.name);
// Validate each array item
applyEach(f.hobbies, (hobby) => {
required(hobby.name, { message: "Hobby name is required" });
min(hobby.years, 0, { message: "Years must be positive" });
});
});
@Component({
template: `
@for (hobby of userForm.hobbies; track hobby; let i = $index) {
<div class="hobby-row">
<input [field]="hobby.name" placeholder="Hobby" />
<input [field]="hobby.years" type="number" placeholder="Years" />
<button type="button" (click)="removeHobby(i)">Remove</button>
</div>
} @empty {
<p>No hobbies added</p>
}
<button type="button" (click)="addHobby()">Add Hobby</button>
`,
})
export class HobbyForm {
user = signal<User>({ name: "", hobbies: [] });
userForm = form(this.user, userSchema);
addHobby(): void {
this.user.update((u) => ({
...u,
hobbies: [...u.hobbies, { name: "", years: 0 }],
}));
}
removeHobby(index: number): void {
this.user.update((u) => ({
...u,
hobbies: u.hobbies.filter((_, i) => i !== index),
}));
}
}typescript
interface Hobby {
name: string;
years: number;
}
interface User {
name: string;
hobbies: Hobby[];
}
const userSchema = schema<User>((f) => {
required(f.name);
// Validate each array item
applyEach(f.hobbies, (hobby) => {
required(hobby.name, { message: "Hobby name is required" });
min(hobby.years, 0, { message: "Years must be positive" });
});
});
@Component({
template: `
@for (hobby of userForm.hobbies; track hobby; let i = $index) {
<div class="hobby-row">
<input [field]="hobby.name" placeholder="Hobby" />
<input [field]="hobby.years" type="number" placeholder="Years" />
<button type="button" (click)="removeHobby(i)">Remove</button>
</div>
} @empty {
<p>No hobbies added</p>
}
<button type="button" (click)="addHobby()">Add Hobby</button>
`,
})
export class HobbyForm {
user = signal<User>({ name: "", hobbies: [] });
userForm = form(this.user, userSchema);
addHobby(): void {
this.user.update((u) => ({
...u,
hobbies: [...u.hobbies, { name: "", years: 0 }],
}));
}
removeHobby(index: number): void {
this.user.update((u) => ({
...u,
hobbies: u.hobbies.filter((_, i) => i !== index),
}));
}
}Field State Properties
字段状态属性
typescript
// Access field state
const field = userForm.name();
field.value(); // Current value (may be debounced)
field.controlValue(); // Non-debounced value
field.valid(); // Is valid
field.invalid(); // Is invalid
field.errors(); // Array of { kind, message }
field.touched(); // User has blurred
field.dirty(); // Value has changed
field.pending(); // Async validation in progress
field.disabled(); // Is disabled
field.hidden(); // Is hidden
field.readonly(); // Is read-only
// Methods
field.reset(); // Mark pristine and untouched
field.markAsTouched(); // Mark as touched
field.markAsDirty(); // Mark as dirtytypescript
// Access field state
const field = userForm.name();
field.value(); // Current value (may be debounced)
field.controlValue(); // Non-debounced value
field.valid(); // Is valid
field.invalid(); // Is invalid
field.errors(); // Array of { kind, message }
field.touched(); // User has blurred
field.dirty(); // Value has changed
field.pending(); // Async validation in progress
field.disabled(); // Is disabled
field.hidden(); // Is hidden
field.readonly(); // Is read-only
// Methods
field.reset(); // Mark pristine and untouched
field.markAsTouched(); // Mark as touched
field.markAsDirty(); // Mark as dirtyForm State with Computed Signals
结合计算信号的表单状态
typescript
@Component({
template: `
<form (ngSubmit)="onSubmit()">
<!-- fields -->
<button type="submit" [disabled]="!canSubmit()">Submit</button>
<p>Form valid: {{ isValid() }}</p>
<p>Has changes: {{ isDirty() }}</p>
</form>
`,
})
export class Form {
user = signal<User>({ name: "", email: "" });
userForm = form(this.user, userSchema);
readonly isValid = computed(() => this.userForm().valid());
readonly isDirty = computed(
() => this.userForm.name().dirty() || this.userForm.email().dirty(),
);
readonly canSubmit = computed(() => this.isValid() && this.isDirty());
}typescript
@Component({
template: `
<form (ngSubmit)="onSubmit()">
<!-- fields -->
<button type="submit" [disabled]="!canSubmit()">Submit</button>
<p>Form valid: {{ isValid() }}</p>
<p>Has changes: {{ isDirty() }}</p>
</form>
`,
})
export class Form {
user = signal<User>({ name: "", email: "" });
userForm = form(this.user, userSchema);
readonly isValid = computed(() => this.userForm().valid());
readonly isDirty = computed(
() => this.userForm.name().dirty() || this.userForm.email().dirty(),
);
readonly canSubmit = computed(() => this.isValid() && this.isDirty());
}With Material Form Fields
与Material表单字段结合使用
typescript
@Component({
imports: [Field, MatFormFieldModule, MatInputModule],
template: `
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput [field]="userForm.email" type="email" />
@if (userForm.email().touched()) {
@for (error of userForm.email().errors(); track error.kind) {
<mat-error>{{ error.message }}</mat-error>
}
}
</mat-form-field>
`,
})typescript
@Component({
imports: [Field, MatFormFieldModule, MatInputModule],
template: `
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput [field]="userForm.email" type="email" />
@if (userForm.email().touched()) {
@for (error of userForm.email().errors(); track error.kind) {
<mat-error>{{ error.message }}</mat-error>
}
}
</mat-form-field>
`,
})Schema Organization
Schema组织
typescript
// src/app/domain/data/models/user.validation.ts
import {
schema,
required,
email,
min,
max,
pattern,
} from "@angular/forms/signals";
export interface User {
name: string;
email: string;
age: number;
}
// Export reusable schema
export const userValidation = schema<User>((f) => {
required(f.name, { message: "Name is required" });
required(f.email, { message: "Email is required" });
email(f.email, { message: "Invalid email" });
min(f.age, 18, { message: "Must be 18 or older" });
max(f.age, 120, { message: "Invalid age" });
});
// Usage in component
import { userValidation } from "../data/models/user.validation";
userForm = form(this.user, userValidation);typescript
// src/app/domain/data/models/user.validation.ts
import {
schema,
required,
email,
min,
max,
pattern,
} from "@angular/forms/signals";
export interface User {
name: string;
email: string;
age: number;
}
// Export reusable schema
export const userValidation = schema<User>((f) => {
required(f.name, { message: "Name is required" });
required(f.email, { message: "Email is required" });
email(f.email, { message: "Invalid email" });
min(f.age, 18, { message: "Must be 18 or older" });
max(f.age, 120, { message: "Invalid age" });
});
// Usage in component
import { userValidation } from "../data/models/user.validation";
userForm = form(this.user, userValidation);Checklist
检查清单
- Define TypeScript interface for form data
- Create schema with validation rules
- Use for form state
signal() - Use to create reactive form
form() - Import directive for bindings
Field - Show errors only when or
touched()dirty() - Track errors by
error.kind - Use for submit button
userForm().valid() - Use OnPush change detection
- 为表单数据定义TypeScript接口
- 创建包含验证规则的Schema
- 使用管理表单状态
signal() - 使用创建响应式表单
form() - 导入指令用于绑定
Field - 仅当字段或
touched()时显示错误dirty() - 通过跟踪错误类型
error.kind - 使用控制提交按钮状态
userForm().valid() - 使用OnPush变更检测策略