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 for Reactive Forms patterns.
Basic Setup
typescript
1import { Component, signal } from '@angular/core';
2import { form, FormField, required, email } from '@angular/forms/signals';
3
4interface LoginData {
5 email: string;
6 password: string;
7}
8
9@Component({
10 selector: 'app-login',
11 imports: [FormField],
12 template: `
13 <form (submit)="onSubmit($event)">
14 <label>
15 Email
16 <input type="email" [formField]="loginForm.email" />
17 </label>
18 @if (loginForm.email().touched() && loginForm.email().invalid()) {
19 <p class="error">{{ loginForm.email().errors()[0].message }}</p>
20 }
21
22 <label>
23 Password
24 <input type="password" [formField]="loginForm.password" />
25 </label>
26 @if (loginForm.password().touched() && loginForm.password().invalid()) {
27 <p class="error">{{ loginForm.password().errors()[0].message }}</p>
28 }
29
30 <button type="submit" [disabled]="loginForm().invalid()">Login</button>
31 </form>
32 `,
33})
34export class LoginComponent {
35 // Form model - a writable signal
36 loginModel = signal<LoginData>({
37 email: '',
38 password: '',
39 });
40
41 // Create form with validation schema
42 loginForm = form(this.loginModel, (schemaPath) => {
43 required(schemaPath.email, { message: 'Email is required' });
44 email(schemaPath.email, { message: 'Enter a valid email address' });
45 required(schemaPath.password, { message: 'Password is required' });
46 });
47
48 onSubmit(event: Event) {
49 event.preventDefault();
50 if (this.loginForm().valid()) {
51 const credentials = this.loginModel();
52 console.log('Submitting:', credentials);
53 }
54 }
55}
Form models are writable signals that serve as the single source of truth:
typescript
1// Define interface for type safety
2interface UserProfile {
3 name: string;
4 email: string;
5 age: number | null;
6 preferences: {
7 newsletter: boolean;
8 theme: 'light' | 'dark';
9 };
10}
11
12// Create model signal with initial values
13const userModel = signal<UserProfile>({
14 name: '',
15 email: '',
16 age: null,
17 preferences: {
18 newsletter: false,
19 theme: 'light',
20 },
21});
22
23// Create form from model
24const userForm = form(userModel);
25
26// Access nested fields via dot notation
27userForm.name // FieldTree<string>
28userForm.preferences.theme // FieldTree<'light' | 'dark'>
Reading Values
typescript
1// Read entire model
2const data = this.userModel();
3
4// Read field value via field state
5const name = this.userForm.name().value();
6const theme = this.userForm.preferences.theme().value();
Updating Values
typescript
1// Replace entire model
2this.userModel.set({
3 name: 'Alice',
4 email: 'alice@example.com',
5 age: 30,
6 preferences: { newsletter: true, theme: 'dark' },
7});
8
9// Update single field
10this.userForm.name().value.set('Bob');
11this.userForm.age().value.update(age => (age ?? 0) + 1);
Field State
Each field provides reactive signals for validation, interaction, and availability:
typescript
1const emailField = this.form.email();
2
3// Validation state
4emailField.valid() // true if passes all validation
5emailField.invalid() // true if has validation errors
6emailField.errors() // array of error objects
7emailField.pending() // true if async validation in progress
8
9// Interaction state
10emailField.touched() // true after focus + blur
11emailField.dirty() // true after user modification
12
13// Availability state
14emailField.disabled() // true if field is disabled
15emailField.hidden() // true if field should be hidden
16emailField.readonly() // true if field is readonly
17
18// Value
19emailField.value() // current field value (signal)
The form itself is also a field with aggregated state:
typescript
1// Form is valid when all interactive fields are valid
2this.form().valid()
3
4// Form is touched when any field is touched
5this.form().touched()
6
7// Form is dirty when any field is modified
8this.form().dirty()
Validation
Built-in Validators
typescript
1import {
2 form, required, email, min, max,
3 minLength, maxLength, pattern
4} from '@angular/forms/signals';
5
6const userForm = form(this.userModel, (schemaPath) => {
7 // Required field
8 required(schemaPath.name, { message: 'Name is required' });
9
10 // Email format
11 email(schemaPath.email, { message: 'Invalid email' });
12
13 // Numeric range
14 min(schemaPath.age, 18, { message: 'Must be 18+' });
15 max(schemaPath.age, 120, { message: 'Invalid age' });
16
17 // String/array length
18 minLength(schemaPath.password, 8, { message: 'Min 8 characters' });
19 maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });
20
21 // Regex pattern
22 pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
23 message: 'Format: 555-123-4567',
24 });
25});
Conditional Validation
typescript
1const orderForm = form(this.orderModel, (schemaPath) => {
2 required(schemaPath.promoCode, {
3 message: 'Promo code required for discounts',
4 when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
5 });
6});
Custom Validators
typescript
1import { validate } from '@angular/forms/signals';
2
3const signupForm = form(this.signupModel, (schemaPath) => {
4 // Custom validation logic
5 validate(schemaPath.username, ({ value }) => {
6 if (value().includes(' ')) {
7 return { kind: 'noSpaces', message: 'Username cannot contain spaces' };
8 }
9 return null;
10 });
11});
Cross-Field Validation
typescript
1const passwordForm = form(this.passwordModel, (schemaPath) => {
2 required(schemaPath.password);
3 required(schemaPath.confirmPassword);
4
5 // Compare fields
6 validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
7 if (value() !== valueOf(schemaPath.password)) {
8 return { kind: 'mismatch', message: 'Passwords do not match' };
9 }
10 return null;
11 });
12});
Async Validation
typescript
1import { validateHttp } from '@angular/forms/signals';
2
3const signupForm = form(this.signupModel, (schemaPath) => {
4 validateHttp(schemaPath.username, {
5 request: ({ value }) => `/api/check-username?u=${value()}`,
6 onSuccess: (response: { taken: boolean }) => {
7 if (response.taken) {
8 return { kind: 'taken', message: 'Username already taken' };
9 }
10 return null;
11 },
12 onError: () => ({
13 kind: 'networkError',
14 message: 'Could not verify username',
15 }),
16 });
17});
Conditional Fields
Hidden Fields
typescript
1import { hidden } from '@angular/forms/signals';
2
3const profileForm = form(this.profileModel, (schemaPath) => {
4 hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
5});
html
1@if (!profileForm.publicUrl().hidden()) {
2 <input [formField]="profileForm.publicUrl" />
3}
Disabled Fields
typescript
1import { disabled } from '@angular/forms/signals';
2
3const orderForm = form(this.orderModel, (schemaPath) => {
4 disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
5});
Readonly Fields
typescript
1import { readonly } from '@angular/forms/signals';
2
3const accountForm = form(this.accountModel, (schemaPath) => {
4 readonly(schemaPath.username); // Always readonly
5});
typescript
1import { submit } from '@angular/forms/signals';
2
3@Component({
4 template: `
5 <form (submit)="onSubmit($event)">
6 <input [formField]="form.email" />
7 <input [formField]="form.password" />
8 <button type="submit" [disabled]="form().invalid()">Submit</button>
9 </form>
10 `,
11})
12export class LoginComponent {
13 model = signal({ email: '', password: '' });
14 form = form(this.model, (schemaPath) => {
15 required(schemaPath.email);
16 required(schemaPath.password);
17 });
18
19 onSubmit(event: Event) {
20 event.preventDefault();
21
22 // submit() marks all fields touched and runs callback if valid
23 submit(this.form, async () => {
24 await this.authService.login(this.model());
25 });
26 }
27}
Arrays and Dynamic Fields
typescript
1interface Order {
2 items: Array<{ product: string; quantity: number }>;
3}
4
5@Component({
6 template: `
7 @for (item of orderForm.items; track $index; let i = $index) {
8 <div>
9 <input [formField]="item.product" placeholder="Product" />
10 <input [formField]="item.quantity" type="number" />
11 <button type="button" (click)="removeItem(i)">Remove</button>
12 </div>
13 }
14 <button type="button" (click)="addItem()">Add Item</button>
15 `,
16})
17export class OrderComponent {
18 orderModel = signal<Order>({
19 items: [{ product: '', quantity: 1 }],
20 });
21
22 orderForm = form(this.orderModel, (schemaPath) => {
23 applyEach(schemaPath.items, (item) => {
24 required(item.product, { message: 'Product required' });
25 min(item.quantity, 1, { message: 'Min quantity is 1' });
26 });
27 });
28
29 addItem() {
30 this.orderModel.update(m => ({
31 ...m,
32 items: [...m.items, { product: '', quantity: 1 }],
33 }));
34 }
35
36 removeItem(index: number) {
37 this.orderModel.update(m => ({
38 ...m,
39 items: m.items.filter((_, i) => i !== index),
40 }));
41 }
42}
Displaying Errors
html
1<input [formField]="form.email" />
2
3@if (form.email().touched() && form.email().invalid()) {
4 <ul class="errors">
5 @for (error of form.email().errors(); track error) {
6 <li>{{ error.message }}</li>
7 }
8 </ul>
9}
10
11@if (form.email().pending()) {
12 <span>Validating...</span>
13}
Styling Based on State
html
1<input
2 [formField]="form.email"
3 [class.is-invalid]="form.email().touched() && form.email().invalid()"
4 [class.is-valid]="form.email().touched() && form.email().valid()"
5/>
typescript
1async onSubmit() {
2 if (!this.form().valid()) return;
3
4 await this.api.submit(this.model());
5
6 // Clear interaction state
7 this.form().reset();
8
9 // Clear values
10 this.model.set({ email: '', password: '' });
11}
For Reactive Forms patterns (production-stable), see references/form-patterns.md.