
Angular Forms
- 6.5k installs
- 594 repo stars
- Updated March 23, 2026
- analogjs/angular-skills
angular-forms is an agent skill for 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
About
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 f --- 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.
- --- 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 Forms by the numbers
- 6,508 all-time installs (skills.sh)
- +102 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #80 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
angular-forms capabilities & compatibility
- Capabilities
- name: angular forms description: build signa · use for form creation with automatic two way bin · triggers on form implementation, adding validati · signal forms are experimental but recommended fo · don't use for template driven forms without sign
- Use cases
- documentation
What angular-forms says it does
--- 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.
npx skills add https://github.com/analogjs/angular-skills --skill angular-formsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6.5k |
|---|---|
| repo stars | ★ 594 |
| Security audit | 3 / 3 scanners passed |
| Last updated | March 23, 2026 |
| Repository | analogjs/angular-skills ↗ |
When should developers use angular-forms and what problem does it solve?
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. Trigg
Who is it for?
Developers working with angular-forms patterns described in the skill documentation.
Skip if: Skip when cached docs are empty or the task is outside the skill's documented scope.
When should I use this skill?
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. Trigg
What you get
Grounded guidance and workflows from SKILL.md for angular-forms.
- Reactive form component code
- Custom validator functions
- FormArray configuration
By the numbers
- Covers six form topics: reactive forms, typed forms, FormBuilder, FormArray, custom validators, and form state managemen
Files
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>
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
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'>Reading Values
// 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
// 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:
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:
// 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
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
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 logic
validate(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 fields
validate(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 valid
submit(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 state
this.form().reset();
// Clear values
this.model.set({ email: '', password: '' });
}For Reactive Forms patterns (production-stable), see references/form-patterns.md.
Angular Form Patterns
Table of Contents
- Reactive Forms (Production-Stable)
- Typed Reactive Forms
- FormBuilder Patterns
- Dynamic Forms with FormArray
- Custom Validators
- Form State Management
Reactive Forms (Production-Stable)
For production applications requiring stability guarantees, use Reactive Forms:
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" />
@if (form.controls.email.errors?.['required'] && form.controls.email.touched) {
<span class="error">Email is required</span>
}
<input type="password" formControlName="password" />
<button type="submit" [disabled]="form.invalid">Login</button>
</form>
`,
})
export class Login {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
}
}
}Typed Reactive Forms
Typed FormControl
import { FormControl } from '@angular/forms';
// Inferred type: FormControl<string | null>
const name = new FormControl('');
// Non-nullable (no reset to null)
const email = new FormControl('', { nonNullable: true });
// Type: FormControl<string>
// With validators
const username = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3)],
});Typed FormGroup
import { FormGroup, FormControl } from '@angular/forms';
interface UserForm {
name: FormControl<string>;
email: FormControl<string>;
age: FormControl<number | null>;
}
const form = new FormGroup<UserForm>({
name: new FormControl('', { nonNullable: true }),
email: new FormControl('', { nonNullable: true }),
age: new FormControl<number | null>(null),
});
// Typed value access
const name: string = form.controls.name.value;NonNullableFormBuilder
import { inject } from '@angular/core';
import { NonNullableFormBuilder } from '@angular/forms';
@Component({...})
export class Profile {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
name: ['', Validators.required], // FormControl<string>
email: ['', [Validators.required, Validators.email]],
preferences: this.fb.group({
newsletter: [false], // FormControl<boolean>
theme: ['light' as 'light' | 'dark'], // FormControl<'light' | 'dark'>
}),
});
}FormBuilder Patterns
Nested FormGroups
@Component({
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" placeholder="Name" />
<div formGroupName="address">
<input formControlName="street" placeholder="Street" />
<input formControlName="city" placeholder="City" />
<input formControlName="zip" placeholder="ZIP" />
</div>
<button type="submit">Submit</button>
</form>
`,
})
export class Profile {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
name: ['', Validators.required],
address: this.fb.group({
street: [''],
city: ['', Validators.required],
zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
}),
});
}Dynamic Forms with FormArray
import { FormArray } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input formControlName="product" placeholder="Product" />
<input formControlName="quantity" type="number" />
<button type="button" (click)="removeItem(i)">Remove</button>
</div>
}
</div>
<button type="button" (click)="addItem()">Add Item</button>
</form>
`,
})
export class Order {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
items: this.fb.array([this.createItem()]),
});
get items() {
return this.form.controls.items;
}
createItem() {
return this.fb.group({
product: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
});
}
addItem() {
this.items.push(this.createItem());
}
removeItem(index: number) {
this.items.removeAt(index);
}
}Custom Validators
Sync Validator
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenValue(forbidden: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return control.value === forbidden
? { forbiddenValue: { value: control.value } }
: null;
};
}
// Usage
name: ['', [Validators.required, forbiddenValue('admin')]],Cross-Field Validator
export function passwordMatch(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
};
}
// Usage
form = this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
}, { validators: passwordMatch() });Async Validator
import { AsyncValidatorFn } from '@angular/forms';
import { map, catchError, of } from 'rxjs';
export function uniqueEmail(userService: User): AsyncValidatorFn {
return (control: AbstractControl) => {
return userService.checkEmail(control.value).pipe(
map(exists => exists ? { emailTaken: true } : null),
catchError(() => of(null))
);
};
}
// Usage
email: ['',
[Validators.required, Validators.email], // sync validators
[uniqueEmail(this.userService)] // async validators
],Form State Management
State Properties
// Check states
form.valid // All validations pass
form.invalid // Has validation errors
form.pending // Async validation in progress
form.dirty // Value changed by user
form.pristine // Value not changed
form.touched // Control has been focused
form.untouched // Control never focused
// Update values
form.setValue({ name: 'John', email: 'john@example.com' }); // Must include all
form.patchValue({ name: 'John' }); // Partial update
// Reset
form.reset();
form.reset({ name: 'Default' });
// Disable/Enable
form.disable();
form.enable();
form.controls.email.disable();
// Mark states
form.markAllAsTouched(); // Show all errors
form.markAsPristine();
form.markAsDirty();Value Changes Observable
// Subscribe to value changes
form.valueChanges.subscribe(value => {
console.log('Form value:', value);
});
// Single control with debounce
form.controls.email.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged()
).subscribe(email => {
this.validateEmail(email);
});
// Status changes
form.statusChanges.subscribe(status => {
console.log('Form status:', status); // VALID, INVALID, PENDING
});Unified Events (Angular v18+)
import {
ValueChangeEvent, StatusChangeEvent,
PristineChangeEvent,TouchedChangeEvent,
FormSubmittedEvent, FormResetEvent
} from '@angular/forms';
form.events.subscribe(event => {
if (event instanceof ValueChangeEvent) {
console.log('Value changed:', event.value);
}
if (event instanceof StatusChangeEvent) {
console.log('Status changed:', event.status);
}
if (event instanceof PristineChangeEvent) {
console.log('Pristine changed:', event.pristine);
}
if (event instanceof TouchedChangeEvent) {
console.log('Touched changed:', event.touched);
}
if (event instanceof FormSubmittedEvent) {
console.log('Form submitted');
}
if (event instanceof FormResetEvent) {
console.log('Form reset');
}
});Error Display Pattern
@Component({
template: `
<input formControlName="email" />
@if (form.controls.email.invalid && form.controls.email.touched) {
<div class="errors">
@if (form.controls.email.errors?.['required']) {
<span>Email is required</span>
}
@if (form.controls.email.errors?.['email']) {
<span>Invalid email format</span>
}
</div>
}
`,
})
export class Form {
// Helper for cleaner templates
hasError(controlName: string, errorKey: string): boolean {
const control = this.form.get(controlName);
return control?.hasError(errorKey) && control?.touched || false;
}
}Form Submission Pattern
@Component({
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- fields -->
<button type="submit" [disabled]="form.invalid || isSubmitting">
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</button>
</form>
`,
})
export class Form {
isSubmitting = false;
async onSubmit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.isSubmitting = true;
try {
await this.api.submit(this.form.getRawValue());
this.form.reset();
} catch (error) {
// Handle error
} finally {
this.isSubmitting = false;
}
}
}Angular Signal Forms - ( FormValueControl )
Table of Contents
Signal Forms FormValueControl
``` typescript
interface Rating { rating : number }
import { form, FormField, FormValueControl, ValidationError, WithOptionalField } from '@angular/forms/signals'; import { MatIconModule } from '@angular/material/icon'; import { MatError } from '@angular/material/form-field';
@Component({ selector: 'app-rating', imports : [MatIconModule,MatError], template: <div class="star-rating-container"> @for (star of starArray(); track $index) { <mat-icon (click)="rate(star)" class="star-icon" [class.readonly]="readonly()" [class.error]="invalid()" [class]="{ filled: star <= value() }" > {{ getStarIcon(star) }} </mat-icon> } @if (errors().at(0)?.message) { <mat-error> {{ errors().at(0)?.message }} </mat-error> } </div> , styles: ``, }) export class Rating implements FormValueControl<number> { // Required: The value of the control, exposed as a two-way binding. readonly value = model<number>(0); // Optional: Bindings for other form control states. readonly readonly = input<boolean>(false); readonly invalid = input<boolean>(false); readonly errors: InputSignal<readonly WithOptionalField<ValidationError>[]> = input< readonly WithOptionalField<ValidationError>[] >([]);
starArray: Signal<number[]> = signal( Array(5) .fill(0) .map((_, i) => i + 1), );
getStarIcon(index: number): string { const floorRating = Math.floor(this.value()); if (index <= floorRating) { return 'star'; // Full star } else { return 'star_border'; // Empty star } } rate(index: number): void { if (!this.readonly()) { this.value.set(index); } } }
import { FormField } from '@angular/forms/signals';
@Component({ selector: 'app-signal-forms', imports : [FormField, Rating], template: ` <form autocomplete="off" (submit)="submit($event)"> <div class="form-field"> <app-rating [formField]="ratingForm.rating">
</app-rating> <!-- print to show the value updation --> {{ratingForm.rating().value()}} </div> </form> , styles: `, }) export class SignalForms { readonly ratingModel = signal<Rating>({ rating: 0, });
readonly ratingForm = form(this.ratingModel)
submit(event: Event): void { event.preventDefault(); console.log(this.ratingForm.rating().value()); } }
Related skills
How it compares
Pick angular-forms when you need Angular-specific reactive form scaffolding; use a general frontend-design skill for layout and styling without form logic.
FAQ
What does angular-forms do?
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 implemen
When should I invoke angular-forms?
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 implemen
Where is the source documentation?
Ground claims in SKILL.md excerpts and linked reference files from the cached docs.
Is Angular Forms safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.