Traditional Reactive Forms vs Signal Forms
Why Angular introduced Signal Forms
Benefits: better reactivity, less boilerplate, fine-grained updates
Angular 21 introduces Signal Forms, a new way to build forms using Angular Signals. Signal Forms provide a more reactive, declarative, and performant approach compared to traditional Reactive Forms.
In this blog, I’ll walk through a practical example that demonstrates validations, dynamic validators, array-like behavior using applyEach(), form submission using signals, and building custom input components
Key Points:
form() creation
Initial values
We start by defining a signal-based form using the form() API. Each field is represented as a signal control, which automatically reacts to value and validation changes.
interface PersonalForm {
firstName: string;
lastName: string;
email: string;
phone: string;
hobbies: string[];
address: string;
isAddressRequired: boolean;
}
export class Home {
readonly personalFormModel = signal<PersonalForm>({
firstName: '',
lastName: '',
});
readonly personalForm = form(this.personalFormModel, (schemaPath) => {
required(schemaPath.firstName, { message: 'First name is required' });
required(schemaPath.lastName, { message: 'Last name is required' });
});
}
3.1 Built-in Validations (Email, Regex, Required)
Signal Forms support all common validations such as required, email, and pattern (regex) validations. These validators run automatically whenever the control value changes.
readonly personalFormModel = signal<PersonalForm>({
email: '',
phone: '',
});
readonly personalForm = form(this.personalFormModel, (schemaPath) => {
required(schemaPath.email, { message: 'Email is required' });
email(schemaPath.email, { message: 'Invalid email format' });
required(schemaPath.phone, { message: 'Phone number is required' });
pattern(schemaPath.phone, /^\+?[0-9]{1,3}?[-.\s]?[0-9]{7,14}$/, {
message: 'Invalid phone number format',
});
});
3.2 Conditional Validators (Add / Remove Dynamically)
One powerful feature of Signal Forms is the ability to dynamically add or remove validators.
For example, when a checkbox is checked, an additional field becomes required. When unchecked, the validation is removed automatically.
Key Points:
readonly personalFormModel = signal<PersonalForm>({
isAddressRequired: false,
address: '',
});
readonly personalForm = form(this.personalFormModel, (schemaPath) => {
required(schemaPath.address, {
message: 'Address is required',
when: ({ valueOf }) => valueOf(schemaPath.isAddressRequired),
});
});
This is a highlight section because many devs struggle with FormArray.
Key Points:
Instead of using FormArray, Signal Forms provide applyEach() to handle repeated form controls.
applyEach() allows us to apply the same validation and structure to every item in a dynamic list, making the code cleaner and easier to manage.
readonly personalFormModel = signal<PersonalForm>({
});
readonly personalForm = form(this.personalFormModel, (schemaPath) => {
applyEach(schemaPath.hobbies, (itemSchema) => {
required(itemSchema, { message: 'Hobby is required' });
});
});
This is new and important.
Key Points:
Angular 21 introduces the submit() API for Signal Forms. When the submit button is clicked, submit() validates the entire form and provides access to the final form value in a reactive way.
submit(this.personalForm, async () => {
// This event is triggered only if the form is valid
alert('Form submitted successfully!');
});
}
This shows real-world usage.
Signal Forms make it easy to create reusable custom input components. By passing a form value control into the component, we can fully integrate validation, value updates, and error handling without additional boilerplate.
Key Points:
export class CustomInput implements FormValueControl<any> {
inputType = input.required<'text' | 'email' | 'textarea' | 'tel' | 'checkbox'>();
placeholder = input<string>('');
id = input<string>('');
value = model<string | number | boolean>('');
toggle(event: any) {
this.value.set(event.target.checked);
}
changeAddress(event: any) {
this.value.set(event.target.value);
}
}
7. Error Handling and UI Feedback
Key Points:
Reading errors()
Showing messages
Touched / dirty states
<form novalidate>
<app-custom-input inputType="text" id="firstName" placeholder="Enter your first name" [field]="personalForm.firstName"></app-custom-input>
@if ((personalForm.firstName().touched() || personalForm.firstName().dirty()) && personalForm.firstName().invalid()) {
@let error = personalForm.firstName().errors()[0].message;
<p class="text-red-500 text-xs italic mb-4">{{ error }}</p>
}
<app-custom-input inputType="text" id="lastName" placeholder="Enter your last name" [field]="personalForm.lastName"></app-custom-input>
@if ((personalForm.lastName().touched() || personalForm.lastName().dirty()) && personalForm.lastName().invalid()) {
@let error = personalForm.lastName().errors()[0].message;
<p class="text-red-500 text-xs italic mb-4">{{ error }}</p>
}
<app-custom-input inputType="email" id="email" placeholder="Enter your email" [field]="personalForm.email"></app-custom-input>
@if ((personalForm.email().touched() || personalForm.email().dirty()) && personalForm.email().invalid()) {
@for (error of personalForm.email().errors(); track error.kind) {
<p class="text-red-500 text-xs italic mb-4">{{ error.message }}</p>
}
}
<app-custom-input inputType="tel" id="phone" placeholder="Enter your phone number" [field]="personalForm.phone"></app-custom-input>
@if ((personalForm.phone().touched() || personalForm.phone().dirty()) && personalForm.phone().invalid()) {
@for (error of personalForm.phone().errors(); track error.kind) {
<p class="text-red-500 text-xs italic mb-4">{{ error.message }}</p>
}
}
<app-custom-input inputType="checkbox" id="required-address" placeholder="Set address as required" [field]="personalForm.isAddressRequired"></app-custom-input>
<app-custom-input inputType="textarea" id="address" placeholder="Enter your address" [field]="personalForm.address"></app-custom-input>
@if ((personalForm.address().touched() || personalForm.address().dirty()) && personalForm.address().invalid()) {
@for (error of personalForm.address().errors(); track error.kind) {
<p class="text-red-500 text-xs italic mb-4">{{ error.message }}</p>
}
}
@for (item of personalForm.hobbies; track $index) {
<div class="flex align-middle">
<app-custom-input inputType="text" id="hobby" placeholder="Enter your hobby" [field]="item"></app-custom-input>
@if($index == 0) {
<button type="button" (click)="addHobby()" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded ml-2 mb-4">Add</button>
} @else {
<button type="button" (click)="removeHobby($index)" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded ml-2 mb-4">Remove</button>
}
</div>
@if (item().invalid() && item().touched() || item().dirty()) {
@for (error of item().errors(); track error.kind) {
<p class="text-red-500 text-xs italic mb-4">{{ error.message }}</p>
}
}
}
<div class="flex justify-between">
<button type="button" (click)="save()" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">Submit</button>
</div>
</form>
8. Final Thoughts
Angular 21 Signal Forms provide a modern, reactive alternative to traditional forms. With built-in validations, conditional logic, applyEach() for dynamic lists, simplified submission, and easy custom input integration, they significantly reduce boilerplate while improving readability and maintainability.
If you’re starting a new Angular application or refactoring an existing one, Signal Forms are definitely worth exploring.
9. GitHub Repository
https://github.com/Rorix-Internal-Apps/Signal-form