From analogjs-angular-skills-3
Build signal-based forms in Angular v21+ using the experimental Signal Forms API with automatic two-way binding, schema-based validation, and dynamic form support.
How this skill is triggered — by the user, by Claude, or both
Slash command
/analogjs-angular-skills-3:angular-formsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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.
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 are writable signals that serve as the single source of truth:
// 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'>
// 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();
// Replace entire model
this.userModel.set({
name: 'Alice',
email: '[email protected]',
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);
Each field provides reactive signals for validation, interaction, and availability:
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)
The form itself is also a field with aggregated state:
// 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()
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',
});
});
const orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.promoCode, {
message: 'Promo code required for discounts',
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
});
});
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;
});
});
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;
});
});
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',
}),
});
});
import { hidden } from '@angular/forms/signals';
const profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
});
@if (!profileForm.publicUrl().hidden()) {
<input [formField]="profileForm.publicUrl" />
}
import { disabled } from '@angular/forms/signals';
const orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
});
import { readonly } from '@angular/forms/signals';
const accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username); // Always readonly
});
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());
});
}
}
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),
}));
}
}
<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>
}
<input
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()"
/>
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.
npx claudepluginhub joshuarweaver/cascade-code-languages-misc-1 --plugin analogjs-angular-skills-3Build type-safe Angular reactive forms with FormGroup, FormControl, Validators, and dynamic FormArrays. Includes custom validators, FormArray manipulation, and best practices.
Provides guidance on Angular Signals for reactive state management with writable signals, computed signals, and effects in Angular 16+ applications.
Generates Angular code and provides architectural guidance on signals, forms, DI, routing, SSR, accessibility, animations, styling, testing, and CLI tooling.