Signal Forms (Experimental) ๐
Angularโs new Signal Forms represent a revolutionary leap forward in form handling! Coming in Angular 21, they bridge the gap between Angularโs reactive signals and user interactions, creating a seamless, type-safe, and highly performant form experience.
๐ฏ What are Signal Forms?
Section titled โ๐ฏ What are Signal Forms?โSignal Forms are the next generation of Angular forms built entirely on signals. They provide reactive form handling with automatic change detection, type safety, and seamless integration with Angularโs modern signal-based architecture.
Key Revolutionary Features:
- ๐ Signal-Based - Built entirely on Angularโs signal system
- โก Automatic Reactivity - No manual change detection needed
- ๐ฏ Type-Safe - Full TypeScript support with compile-time validation
- ๐งน Clean API - Simplified, intuitive form creation and management
- ๐ Performance - Optimized for modern Angular applications
- ๐ Seamless Integration - Works perfectly with standalone components and signals
๐ Traditional vs Signal Forms
Section titled โ๐ Traditional vs Signal Formsโโ Traditional Reactive Forms
Section titled โโ Traditional Reactive Formsโ// Complex setup with FormBuilderexport class UserFormComponent { userForm = this.fb.group({ name: ['', [Validators.required, Validators.minLength(3)]], email: ['', [Validators.required, Validators.email]], age: [0, [Validators.required, Validators.min(18)]] });
constructor(private fb: FormBuilder) {}
onSubmit() { if (this.userForm.valid) { console.log(this.userForm.value); } }}โ Modern Signal Forms
Section titled โโ Modern Signal Formsโimport { form, required, minLength, email, min, submit } from '@angular/forms/signals';
export class UserFormComponent { // Simple, type-safe form creation user = signal({ name: '', email: '', age: 0 });
userForm = form(this.user, (path) => { required(path.name); minLength(path.name, 3); required(path.email); email(path.email); required(path.age); min(path.age, 18); });
onSubmit() { submit(this.userForm, async (form) => { // Automatically validated - only runs if form is valid console.log(form().value()); return null; // No errors }); }}๐ Basic Signal Form Setup
Section titled โ๐ Basic Signal Form Setupโ1. Simple Contact Form
Section titled โ1. Simple Contact Formโimport { Component, signal } from '@angular/core';import { form, required, email, minLength, Control } from '@angular/forms/signals';
interface Contact { name: string; email: string; message: string;}
@Component({ selector: 'app-contact-form', imports: [Control], template: ` <form class="contact-form"> <div class="form-group"> <label for="name">Name</label> <input [field]="contactForm.name" id="name" /> @if (contactForm.name().errors().length > 0) { <div class="error">{{ contactForm.name().errors()[0].message }}</div> } </div>
<div class="form-group"> <label for="email">Email</label> <input [field]="contactForm.email" id="email" type="email" /> @if (contactForm.email().errors().length > 0) { <div class="error">{{ contactForm.email().errors()[0].message }}</div> } </div>
<div class="form-group"> <label for="message">Message</label> <textarea [field]="contactForm.message" id="message"></textarea> @if (contactForm.message().errors().length > 0) { <div class="error">{{ contactForm.message().errors()[0].message }}</div> } </div>
<button type="button" (click)="onSubmit()" [disabled]="contactForm().invalid()"> Send Message </button> </form> `})export class ContactFormComponent { contact = signal<Contact>({ name: '', email: '', message: '' });
contactForm = form(this.contact, (path) => { required(path.name); minLength(path.name, 2); required(path.email); email(path.email); required(path.message); minLength(path.message, 10); });
onSubmit() { submit(this.contactForm, async (form) => { console.log('Sending message:', form().value()); // Simulate API call await new Promise(resolve => setTimeout(resolve, 1000)); alert('Message sent successfully!'); return null; }); }}2. Custom Validators
Section titled โ2. Custom Validatorsโimport { validate, customError, FieldPath } from '@angular/forms/signals';
// Custom validator functionfunction validateUsername(path: FieldPath<string>) { validate(path, (ctx) => { const value = ctx.value(); const forbidden = ['admin', 'root', 'user'];
if (forbidden.includes(value.toLowerCase())) { return customError({ kind: 'forbidden_username', message: `Username "${value}" is not allowed`, value, forbidden }); } return null; });}
// Usage in formuserForm = form(this.user, (path) => { required(path.username); minLength(path.username, 3); validateUsername(path.username);});๐ Advanced Features
Section titled โ๐ Advanced Featuresโ1. Conditional Validation
Section titled โ1. Conditional Validationโimport { Component, signal } from '@angular/core';import { applyEach, Control, form, minLength, required, schema, submit } from '@angular/forms/signals';
interface TodoItem { id: number; text: string; completed: boolean; priority: 'low' | 'medium' | 'high';}
interface TodoList { title: string; items: TodoItem[];}
// Schema for individual todo itemsconst todoItemSchema = schema<TodoItem>((path) => { required(path.text); minLength(path.text, 3); required(path.priority);});
@Component({ template: ` <form class="todo-form"> <div class="form-group"> <label>List Title</label> <input [field]="todoForm.title" /> </div>
<fieldset> <legend>Todo Items</legend> @for (item of todoForm.items; track item; let i = $index) { <div class="todo-item"> <input [field]="item.text" placeholder="Todo text" /> <select [field]="item.priority"> <option value="low">Low</option> <option value="medium">Medium</option> <option value="high">High</option> </select> <input type="checkbox" [field]="item.completed" /> <button type="button" (click)="removeItem(i)">Remove</button> </div> } <button type="button" (click)="addItem()">Add Todo</button> </fieldset>
<button type="button" (click)="saveTodos()" [disabled]="todoForm().invalid()"> Save Todos </button> </form> `, imports: [Control]})export class TodoFormComponent { todoList = signal<TodoList>({ title: '', items: [] });
todoForm = form(this.todoList, (path) => { required(path.title); minLength(path.title, 3); applyEach(path.items, todoItemSchema); });
addItem() { const items = this.todoForm.items(); items.value.update(current => [ ...current, { id: Date.now(), text: '', completed: false, priority: 'medium' as const } ]); }
removeItem(index: number) { const items = this.todoForm.items(); items.value.update(current => current.filter((_, i) => i !== index) ); }
saveTodos() { submit(this.todoForm, async (form) => { console.log('Saving todos:', form().value()); return null; }); }}2. Nested Object Forms
Section titled โ2. Nested Object Formsโinterface Address { street: string; city: string; country: string; zipCode: string;}
interface Company { name: string; address: Address; employees: number;}
const addressSchema = schema<Address>((path) => { required(path.street); required(path.city); required(path.country); required(path.zipCode);});
@Component({ template: ` <form class="company-form"> <fieldset> <legend>Company Information</legend> <input [field]="companyForm.name" placeholder="Company Name" /> <input [field]="companyForm.employees" type="number" placeholder="Employees" /> </fieldset>
<fieldset> <legend>Address</legend> <input [field]="companyForm.address.street" placeholder="Street" /> <input [field]="companyForm.address.city" placeholder="City" /> <input [field]="companyForm.address.country" placeholder="Country" /> <input [field]="companyForm.address.zipCode" placeholder="ZIP Code" /> </fieldset> </form> `})export class CompanyFormComponent { company = signal<Company>({ name: '', address: { street: '', city: '', country: '', zipCode: '' }, employees: 0 });
companyForm = form(this.company, (path) => { required(path.name); required(path.employees); min(path.employees, 1); apply(path.address, addressSchema); });}๐จ Form State Management
Section titled โ๐จ Form State Managementโ1. Form Status and Properties
Section titled โ1. Form Status and Propertiesโ@Component({ template: ` <div class="form-status"> <p>Form Valid: {{ userForm().valid() }}</p> <p>Form Dirty: {{ userForm().dirty() }}</p> <p>Form Touched: {{ userForm().touched() }}</p> <p>Form Pending: {{ userForm().pending() }}</p> </div>
<div class="field-status"> <p>Name Valid: {{ userForm.name().valid() }}</p> <p>Name Dirty: {{ userForm.name().dirty() }}</p> <p>Name Touched: {{ userForm.name().touched() }}</p> <p>Name Errors: {{ userForm.name().errors().length }}</p> </div>
<!-- Error Summary for entire form --> <div class="error-summary"> @for (error of userForm().errorSummary(); track error) { <div class="error">{{ error.message }}</div> } </div> `})export class FormStatusComponent { // Form implementation...}2. Conditional Field States
Section titled โ2. Conditional Field Statesโimport { disabled, hidden, readonly } from '@angular/forms/signals';
userForm = form(this.user, (path) => { required(path.userType);
// Conditionally disable admin fields disabled(path.adminLevel, (ctx) => ctx.valueOf(path.userType) !== 'admin');
// Hide sensitive fields for regular users hidden(path.secretKey, (ctx) => ctx.valueOf(path.userType) !== 'admin');
// Make email readonly after verification readonly(path.email, (ctx) => ctx.valueOf(path.emailVerified));});โ Best Practices
Section titled โโ Best Practicesโ1. Schema Organization
Section titled โ1. Schema Organizationโ// โ
Good - Separate schemas for reusabilityexport const userSchema = schema<User>((path) => { required(path.name); email(path.email);});
export const addressSchema = schema<Address>((path) => { required(path.street); required(path.city);});
// โ
Good - Compose schemasexport const userWithAddressSchema = schema<UserWithAddress>((path) => { apply(path.user, userSchema); apply(path.address, addressSchema);});2. Error Handling
Section titled โ2. Error Handlingโ// โ
Good - Centralized error display component@Component({ selector: 'app-field-errors', template: ` @for (error of errors; track error) { <div class="error" [attr.data-error-kind]="error.kind"> {{ getErrorMessage(error) }} </div> } `})export class FieldErrorsComponent { @Input() errors: ValidationError[] = [];
getErrorMessage(error: ValidationError): string { switch (error.kind) { case 'required': return 'This field is required'; case 'email': return 'Please enter a valid email'; case 'minLength': return `Minimum length is ${error.minLength}`; default: return error.message || 'Invalid value'; } }}3. Form Submission
Section titled โ3. Form Submissionโ// โ
Good - Proper error handling in submitasync onSubmit() { submit(this.userForm, async (form) => { try { const result = await this.userService.saveUser(form().value()); this.router.navigate(['/users', result.id]); return null; // Success } catch (error) { if (error.status === 409) { return { kind: 'duplicate_email', message: 'Email already exists', field: form().field.email }; } return { kind: 'server_error', message: 'Failed to save user. Please try again.' }; } });}๐ฏ Quick Migration Guide
Section titled โ๐ฏ Quick Migration GuideโFrom Reactive Forms to Signal Forms
Section titled โFrom Reactive Forms to Signal Formsโ// โ Old Reactive Formsexport class OldComponent { form = this.fb.group({ name: ['', Validators.required], email: ['', [Validators.required, Validators.email]] });
onSubmit() { if (this.form.valid) { console.log(this.form.value); } }}
// โ
New Signal Formsexport class NewComponent { user = signal({ name: '', email: '' });
form = form(this.user, (path) => { required(path.name); required(path.email); email(path.email); });
onSubmit() { submit(this.form, async (form) => { console.log(form().value()); return null; }); }}๐ Next Steps
Section titled โ๐ Next Stepsโ- Angular Signals - Master the foundation of Signal Forms
- Computed Signals - Reactive derived state
- Effects API - Handle side effects with signals
Remember: Signal Forms are currently experimental in Angular 21 but represent the future of form handling in Angular. They provide a cleaner, more reactive, and type-safe approach to building complex forms! ๐โจ