---
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: `
`,
})
export class Login {
// Form model - a writable signal
loginModel = signal({
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({
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
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()) {
}
```
### 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: `
`,
})
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) {