Skill v1.0.1
currentAutomated scan100/1003 files
version: "1.0.1" 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 for Reactive Forms patterns.
Basic Setup
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><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 signalloginModel = signal<LoginData>({email: '',password: '',});// Create form with validation schemaloginForm = 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:
// Define interface for type safetyinterface UserProfile {name: string;email: string;age: number | null;preferences: {newsletter: boolean;theme: 'light' | 'dark';};}// Create model signal with initial valuesconst userModel = signal<UserProfile>({name: '',email: '',age: null,preferences: {newsletter: false,theme: 'light',},});// Create form from modelconst userForm = form(userModel);// Access nested fields via dot notationuserForm.name // FieldTree<string>userForm.preferences.theme // FieldTree<'light' | 'dark'>
Reading Values
// Read entire modelconst data = this.userModel();// Read field value via field stateconst name = this.userForm.name().value();const theme = this.userForm.preferences.theme().value();
Updating Values
// Replace entire modelthis.userModel.set({name: 'Alice',email: 'alice@example.com',age: 30,preferences: { newsletter: true, theme: 'dark' },});// Update single fieldthis.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:
const emailField = this.form.email();// Validation stateemailField.valid() // true if passes all validationemailField.invalid() // true if has validation errorsemailField.errors() // array of error objectsemailField.pending() // true if async validation in progress// Interaction stateemailField.touched() // true after focus + bluremailField.dirty() // true after user modification// Availability stateemailField.disabled() // true if field is disabledemailField.hidden() // true if field should be hiddenemailField.readonly() // true if field is readonly// ValueemailField.value() // current field value (signal)
Form-Level State
The form itself is also a field with aggregated state:
// Form is valid when all interactive fields are validthis.form().valid()// Form is touched when any field is touchedthis.form().touched()// Form is dirty when any field is modifiedthis.form().dirty()
Validation
Built-in Validators
import {form, required, email, min, max,minLength, maxLength, pattern} from '@angular/forms/signals';const userForm = form(this.userModel, (schemaPath) => {// Required fieldrequired(schemaPath.name, { message: 'Name is required' });// Email formatemail(schemaPath.email, { message: 'Invalid email' });// Numeric rangemin(schemaPath.age, 18, { message: 'Must be 18+' });max(schemaPath.age, 120, { message: 'Invalid age' });// String/array lengthminLength(schemaPath.password, 8, { message: 'Min 8 characters' });maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });// Regex patternpattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {message: 'Format: 555-123-4567',});});
Conditional Validation
const orderForm = form(this.orderModel, (schemaPath) => {required(schemaPath.promoCode, {message: 'Promo code required for discounts',when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),});});
Custom Validators
import { validate } from '@angular/forms/signals';const signupForm = form(this.signupModel, (schemaPath) => {// Custom validation logicvalidate(schemaPath.username, ({ value }) => {if (value().includes(' ')) {return { kind: 'noSpaces', message: 'Username cannot contain spaces' };}return null;});});
Cross-Field Validation
const passwordForm = form(this.passwordModel, (schemaPath) => {required(schemaPath.password);required(schemaPath.confirmPassword);// Compare fieldsvalidate(schemaPath.confirmPassword, ({ value, valueOf }) => {if (value() !== valueOf(schemaPath.password)) {return { kind: 'mismatch', message: 'Passwords do not match' };}return null;});});
Async Validation
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
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" />}
Disabled Fields
import { disabled } from '@angular/forms/signals';const orderForm = form(this.orderModel, (schemaPath) => {disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);});
Readonly Fields
import { readonly } from '@angular/forms/signals';const accountForm = form(this.accountModel, (schemaPath) => {readonly(schemaPath.username); // Always readonly});
Form Submission
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 validsubmit(this.form, async () => {await this.authService.login(this.model());});}}
Arrays and Dynamic Fields
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
<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
<input[formField]="form.email"[class.is-invalid]="form.email().touched() && form.email().invalid()"[class.is-valid]="form.email().touched() && form.email().valid()"/>
Reset Form
async onSubmit() {if (!this.form().valid()) return;await this.api.submit(this.model());// Clear interaction statethis.form().reset();// Clear valuesthis.model.set({ email: '', password: '' });}
For Reactive Forms patterns (production-stable), see references/form-patterns.md.