Angular Signal-Based Forms: Why They're About to Change Everything You Know About Form Handling
The Future of Angular Forms is Here, and It's Powered by Signals

Development is My Passion | Architecting is My Impact | 10 Library author | 100 + Technical Articles | 3+ Tech Talks
With over a decade of experience in front-end development, I specialize in crafting pixel-perfect, user-centric websites and applications. My extensive expertise covers a wide range of technologies, including React, Redux, Angular, Ngrx, Vue.js, React Native, JavaScript (ES6โ12), TypeScript, OOJS, HTML, CSS, Microservices, Unit Testing (Jasmine, Karma, Jest), Playwright, IndexedDB, Bootstrap, Tailwind, web security, accessibility, and performance optimization.
I am adept at implementing highly interactive, optimized, and scalable web applications, with a strong emphasis on efficient algorithms, object-oriented programming, and design patterns. My proficiency in creating responsive designs has not only enhanced user experiences but also significantly reduced mobile development costs.
Throughout my career, I've made impactful contributions to cloud-based applications across diverse sectors, including eCommerce, telecommunications, delivery services, food, energy automation, and finance. My work in these areas has consistently driven success and innovation.
I am dedicated to continuous learning and staying at the forefront of web development trends and technologies. My motivation lies in leading and contributing to the development of next-generation products, ensuring they are fast, optimized, robust, cloud-native, fault-tolerant, and scalable.
In my current role, I have been instrumental in the success of several high-impact projects, including the redesign of a major energy automation platform. This initiative led to a 24% increase in revenue, a 47% boost in performance, and a 32% growth in customer acquisition. With BE driven page, reduce 13% development effort
In addition to building scalable, high-performance products, I actively leverage AI-driven tools (ChatGPT, GitHub Copilot, AI-assisted prototyping) to accelerate development, boost team productivity, and bring user-centric ideas to life faster.
I am excited to further develop my leadership skills and contribute to organizations that are looking to achieve ambitious goals through cutting-edge front-end development.
If you're seeking a leader and contributor who can drive the creation of modern, high-performing web applications.
Let's get in touch.
โ ๏ธ Important Note: Signal-Based Forms are currently an experimental feature in Angular. The APIs shown in this article may change when the feature is officially released. I'll keep updating this article as the feature evolves, so bookmark it for the latest patterns!
Have you ever found yourself writing the same FormGroup and FormControl setup over and over again? Or struggled with complex validation logic that felt more like wrestling with the framework than building features?
What if I told you that Angular's experimental Signal-Based Forms could eliminate 70% of your form boilerplate while making your code more reactive and maintainable?
By the end of this article, you'll understand:
How Signal-Based Forms work under the hood
When to choose them over Reactive and Template-Driven forms
How to implement complex validation scenarios with ease
Best practices for testing signal-based forms
Real-world patterns that you can use immediately
Let's dive into what might be the biggest game-changer for Angular developers since standalone components.
Why Signal-Based Forms Matter (And Why Now?)
Think about the last form you built. How much time did you spend on setup versus actual business logic?
Traditional Reactive Forms require you to:
Define a FormGroup structure
Map it to your data model
Handle validation manually
Sync state between form and component
Deal with subscription management
Signal-Based Forms flip this approach. Instead of building forms from scratch, you start with your data model and let Angular generate the form structure automatically.
Here's what this looks like in practice:
Old Way (Reactive Forms)
export class OldProfileComponent {
profileForm = new FormGroup({
firstName: new FormControl('', [Validators.required, Validators.minLength(2)]),
lastName: new FormControl('', [Validators.required, Validators.minLength(2)]),
email: new FormControl('', [Validators.required, Validators.email]),
notifyByEmail: new FormControl(false)
});
onSubmit() {
if (this.profileForm.valid) {
this.userService.updateProfile(this.profileForm.value);
}
}
}
New Way (Signal-Based Forms)
import { Component, signal } from '@angular/core';
import { form, Control, required, minLength, email } from '@angular/forms/signals';
@Component({
selector: 'app-profile',
imports: [Control],
template: `<!-- template here -->`
})
export class ProfileComponent {
user = signal({
firstName: '',
lastName: '',
email: '',
notifyByEmail: false
});
profileForm = form(this.user, (path) => [
required(path.firstName, { message: 'First name is required' }),
minLength(path.firstName, 2, { message: 'Must be at least 2 characters' }),
required(path.lastName, { message: 'Last name is required' }),
minLength(path.lastName, 2, { message: 'Must be at least 2 characters' }),
required(path.email, { message: 'Email is required' }),
email(path.email, { message: 'Invalid email format' }),
// Conditional validation with 'when'
required(path.email, {
message: 'Email required when notifications are enabled',
when: ({ valueOf }) => valueOf(path.notifyByEmail) === true
})
]);
async onSubmit() {
await submit(this.profileForm, async () => {
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.profileForm.value())
});
if (!response.ok) {
return [
{
kind: 'server',
message: 'Failed to save user',
field: this.profileForm.email
}
];
}
return undefined; // success
} catch (err) {
return [{ kind: 'server', message: 'Unexpected error' }];
}
});
}
}
Notice the difference? The signal-based approach eliminates the manual form structure definition and provides built-in submission handling with the submit() function.
Setting Up Your First Signal-Based Form
Let's build a real-world example step by step. We'll create a user registration form that showcases the key features.
Step 1: Define Your Data Model
interface UserRegistration {
personalInfo: {
firstName: string;
lastName: string;
email: string;
};
preferences: {
newsletter: boolean;
notifications: boolean;
};
accountType: 'personal' | 'business';
password: string;
confirmPassword: string;
}
Step 2: Create the Component
import { Component, signal, computed } from '@angular/core';
import { form, Control, required, email, minLength, pattern, submit } from '@angular/forms/signals';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-registration',
imports: [Control, CommonModule],
template: `
<form (ngSubmit)="onSubmit()">
<fieldset>
<legend>Personal Information</legend>
<div class="field">
<label for="firstName">First Name</label>
<input
id="firstName"
type="text"
[control]="registrationForm.personalInfo.firstName"
placeholder="Enter your first name"
/>
@if (registrationForm.personalInfo.firstName.errors(); as errors) {
@for (error of errors; track error.code) {
<span class="error">{{ error.message }}</span>
}
}
</div>
<div class="field">
<label for="lastName">Last Name</label>
<input
id="lastName"
type="text"
[control]="registrationForm.personalInfo.lastName"
placeholder="Enter your last name"
/>
@if (registrationForm.personalInfo.lastName.errors(); as errors) {
@for (error of errors; track error.code) {
<span class="error">{{ error.message }}</span>
}
}
</div>
<div class="field">
<label for="email">Email</label>
<input
id="email"
type="email"
[control]="registrationForm.personalInfo.email"
placeholder="Enter your email"
/>
@if (registrationForm.personalInfo.email.errors(); as errors) {
@for (error of errors; track error.code) {
<span class="error">{{ error.message }}</span>
}
}
</div>
<div class="field">
<label for="accountType">Account Type</label>
<select
id="accountType"
[control]="registrationForm.accountType"
>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
</div>
</fieldset>
<fieldset>
<legend>Preferences</legend>
<label class="checkbox">
<input
type="checkbox"
[control]="registrationForm.preferences.newsletter"
/>
Subscribe to newsletter
</label>
<label class="checkbox">
<input
type="checkbox"
[control]="registrationForm.preferences.notifications"
/>
Enable notifications
</label>
</fieldset>
<fieldset>
<legend>Security</legend>
<div class="field">
<label for="password">Password</label>
<input
id="password"
type="password"
[control]="registrationForm.password"
placeholder="Enter password"
/>
@if (registrationForm.password.errors(); as errors) {
@for (error of errors; track error.code) {
<span class="error">{{ error.message }}</span>
}
}
</div>
<div class="field">
<label for="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
[control]="registrationForm.confirmPassword"
placeholder="Confirm your password"
/>
@if (registrationForm.confirmPassword.errors(); as errors) {
@for (error of errors; track error.code) {
<span class="error">{{ error.message }}</span>
}
}
</div>
</fieldset>
<div class="form-actions">
<button
type="submit"
[disabled]="registrationForm.submitting() || !registrationForm.valid()"
>
@if (registrationForm.submitting()) {
<span>Creating Account...</span>
} @else {
<span>Create Account</span>
}
</button>
</div>
@if (registrationForm.submitError(); as error) {
<div class="submit-error">
{{ error.message }}
</div>
}
</form>
`,
styles: [`
form {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
fieldset {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
legend {
font-weight: bold;
padding: 0 0.5rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input[type="text"], input[type="email"], input[type="password"], select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.checkbox {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.checkbox input {
width: auto;
margin-right: 0.5rem;
}
.error {
color: #e53e3e;
font-size: 0.875rem;
display: block;
margin-top: 0.25rem;
}
.form-actions {
margin-top: 2rem;
}
button {
background-color: #3182ce;
color: white;
padding: 0.75rem 2rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover:not(:disabled) {
background-color: #2c5aa0;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.submit-error {
background-color: #fed7d7;
color: #c53030;
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
}
`]
})
export class RegistrationComponent {
userRegistration = signal<UserRegistration>({
personalInfo: {
firstName: '',
lastName: '',
email: ''
},
preferences: {
newsletter: false,
notifications: true
},
accountType: 'personal',
password: '',
confirmPassword: ''
});
registrationForm = form(this.userRegistration, (path) => [
// Basic field validation
required(path.personalInfo.firstName, {
message: 'First name is required'
}),
minLength(path.personalInfo.firstName, 2, {
message: 'First name must be at least 2 characters'
}),
required(path.personalInfo.lastName, {
message: 'Last name is required'
}),
minLength(path.personalInfo.lastName, 2, {
message: 'Last name must be at least 2 characters'
}),
required(path.personalInfo.email, {
message: 'Email is required'
}),
email(path.personalInfo.email, {
message: 'Please enter a valid email address'
}),
// Conditional validation using 'when'
required(path.personalInfo.email, {
message: 'Email required when notifications are enabled',
when: ({ valueOf }) => valueOf(path.preferences.notifications) === true
}),
// Business email pattern only when newsletter is enabled
pattern(path.personalInfo.email, /^[a-zA-Z0-9._%+-]+@(?!gmail|yahoo|hotmail|outlook)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, {
message: 'Business email required for newsletter subscriptions',
when: ({ valueOf }) => valueOf(path.preferences.newsletter) === true
}),
// Different validation based on account type
minLength(path.personalInfo.firstName, 5, {
message: 'Business accounts require longer names',
when: ({ valueOf }) => valueOf(path.accountType) === 'business'
}),
required(path.password, {
message: 'Password is required'
}),
minLength(path.password, 8, {
message: 'Password must be at least 8 characters'
}),
required(path.confirmPassword, {
message: 'Please confirm your password',
when: ({ valueOf }) => valueOf(path.password) !== ''
})
]);
async onSubmit() {
await submit(this.registrationForm, async () => {
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.registrationForm.value())
});
if (!response.ok) {
return [
{
kind: 'server',
message: 'Email already exists',
field: this.registrationForm.personalInfo.email
}
];
}
const result = await response.json();
console.log('Registration successful:', result);
return undefined;
} catch (error) {
return [
{ kind: 'server', message: 'Network error. Please try again.' }
];
}
});
}
}
What's happening here?
Signal-based model: We define our data structure as a signal
Path-based validation: The
form()function uses path-based validatorsConditional validation: We use
when: ({ valueOf })for dynamic validationNew control flow syntax: We use
@ifand@forinstead ofngIfandngForBuilt-in submission handling: The
submit()function manages loading states and errors automatically
Advanced Validation Patterns with Schema and Apply
One of the most powerful features of Signal-Based Forms is reusable validation schemas using schema() and apply():
Creating Reusable Schemas
import { schema, apply, form, required, minLength, email, pattern } from '@angular/forms/signals';
// Define reusable validation schemas
const nameSchema = schema<string>((path) => [
required(path, { message: 'This field is required' }),
minLength(path, 2, { message: 'Must be at least 2 characters' }),
pattern(path, /^[a-zA-Z\s'-]+$/, {
message: 'Only letters, spaces, hyphens and apostrophes allowed'
})
]);
const emailSchema = schema<string>((path) => [
required(path, { message: 'Email is required' }),
email(path, { message: 'Please enter a valid email address' })
]);
const businessEmailSchema = schema<string>((path) => [
required(path, { message: 'Business email is required' }),
email(path, { message: 'Invalid email format' }),
pattern(path, /^[a-zA-Z0-9._%+-]+@(?!gmail|yahoo|hotmail|outlook)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, {
message: 'Business domain required'
})
]);
// Apply schemas to form fields
registrationForm = form(this.userRegistration, (path) => [
apply(path.personalInfo.firstName, nameSchema),
apply(path.personalInfo.lastName, nameSchema),
// Conditional schema application
apply(path.personalInfo.email, businessEmailSchema, {
when: ({ valueOf }) => valueOf(path.preferences.newsletter) === true
}),
apply(path.personalInfo.email, emailSchema, {
when: ({ valueOf }) => valueOf(path.preferences.newsletter) === false
}),
// Additional conditional validation
minLength(path.personalInfo.firstName, 5, {
message: 'Business accounts require longer names',
when: ({ valueOf }) => valueOf(path.accountType) === 'business'
})
]);
Schema Composition for Complex Validation
// Base schema for all text inputs
const baseTextSchema = schema<string>((path) => [
required(path, { message: 'This field is required' }),
minLength(path, 1, { message: 'Field cannot be empty' })
]);
// Extended schema for names
const enhancedNameSchema = schema<string>((path) => [
...baseTextSchema(path), // Spread base validation
maxLength(path, 50, { message: 'Name cannot exceed 50 characters' }),
pattern(path, /^[a-zA-Z\s'-]+$/, { message: 'Invalid characters detected' })
]);
// Usage in form with complex conditions
registrationForm = form(this.userRegistration, (path) => [
apply(path.personalInfo.firstName, enhancedNameSchema),
apply(path.personalInfo.lastName, enhancedNameSchema),
// Multiple conditional validations
required(path.personalInfo.email, {
message: 'Email required when notifications are enabled',
when: ({ valueOf }) => valueOf(path.preferences.notifications) === true
}),
pattern(path.personalInfo.email, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, {
message: 'Personal email format is invalid',
when: ({ valueOf }) => valueOf(path.accountType) === 'personal'
}),
pattern(path.personalInfo.email, /^[a-zA-Z0-9._%+-]+@(?!gmail|yahoo|hotmail|outlook)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, {
message: 'Business email required for business accounts',
when: ({ valueOf }) => valueOf(path.accountType) === 'business'
})
]);
Built-in Submission Handling with submit()
The submit() function is a game-changer for handling form submissions. It automatically manages loading states and error handling:
import { submit } from '@angular/forms/signals';
async onSubmit() {
await submit(this.registrationForm, async () => {
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.registrationForm.value())
});
if (!response.ok) {
const errorData = await response.json();
// Map server errors to specific fields
return [
{
kind: 'server',
message: 'This email is already registered',
field: this.registrationForm.personalInfo.email
}
];
}
// Success - return undefined
return undefined;
} catch (error) {
// Global error
return [
{ kind: 'server', message: 'Network error. Please try again.' }
];
}
});
}
Template Integration with Submission States
The submit() function automatically provides these signals:
submitting()โ true while request is in progresssubmitError()โ contains error information if submission failssubmitSuccess()โ indicates successful submission
<button
type="submit"
[disabled]="registrationForm.submitting() || !registrationForm.valid()"
>
@if (registrationForm.submitting()) {
<span>Creating Account...</span>
} @else {
<span>Create Account</span>
}
</button>
Advanced Angular Features: Dynamic Forms and Runtime Controls
Here are some exciting patterns that Signal-Based Forms enable:
Runtime Creation and Removal of Form Controls
@Component({
selector: 'app-dynamic-form-builder',
template: `
<div class="form-builder">
<h2>Dynamic Form Builder</h2>
<div class="control-types">
<button (click)="addField('text')">Add Text Field</button>
<button (click)="addField('email')">Add Email Field</button>
<button (click)="addField('select')">Add Select Field</button>
</div>
<form (ngSubmit)="onSubmit()">
@for (field of formFields(); track field.id) {
<div class="dynamic-field">
<div class="field-header">
<span>{{ field.label }}</span>
<button type="button" (click)="removeField(field.id)">Remove</button>
</div>
@switch (field.type) {
@case ('text') {
<input
type="text"
[control]="getFieldControl(field.id)"
[placeholder]="field.placeholder || ''"
/>
}
@case ('email') {
<input
type="email"
[control]="getFieldControl(field.id)"
placeholder="Enter email"
/>
}
@case ('select') {
<select [control]="getFieldControl(field.id)">
<option value="">Choose...</option>
@for (option of field.options; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
}
}
@if (getFieldControl(field.id).errors(); as errors) {
@for (error of errors; track error.code) {
<span class="error">{{ error.message }}</span>
}
}
</div>
}
@if (formFields().length > 0) {
<button
type="submit"
[disabled]="!dynamicForm.valid() || dynamicForm.submitting()"
>
Submit
</button>
}
</form>
</div>
`
})
export class DynamicFormBuilderComponent {
private fieldCounter = 0;
formFields = signal<Array<{
id: string;
type: 'text' | 'email' | 'select';
label: string;
placeholder?: string;
options?: Array<{ value: string; label: string }>;
}>>([]);
// Create form data structure reactively
formData = computed(() => {
const data: Record<string, any> = {};
this.formFields().forEach(field => {
data[field.id] = '';
});
return data;
});
// Create dynamic form with reactive validation
dynamicForm = computed(() => {
const dataSignal = signal(this.formData());
return form(dataSignal, (path) => {
return this.formFields().map(field => {
const fieldPath = path[field.id];
const validators = [];
validators.push(
required(fieldPath, {
message: `${field.label} is required`
})
);
if (field.type === 'email') {
validators.push(
email(fieldPath, {
message: 'Please enter a valid email address'
})
);
}
return validators;
}).flat();
});
});
addField(type: 'text' | 'email' | 'select') {
this.fieldCounter++;
const id = `field_${this.fieldCounter}`;
let newField;
switch (type) {
case 'text':
newField = {
id,
type,
label: `Text Field ${this.fieldCounter}`,
placeholder: 'Enter text...'
};
break;
case 'email':
newField = {
id,
type,
label: `Email Field ${this.fieldCounter}`
};
break;
case 'select':
newField = {
id,
type,
label: `Select Field ${this.fieldCounter}`,
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' }
]
};
break;
}
this.formFields.update(fields => [...fields, newField]);
}
removeField(fieldId: string) {
this.formFields.update(fields =>
fields.filter(field => field.id !== fieldId)
);
}
getFieldControl(fieldId: string) {
return this.dynamicForm()[fieldId];
}
async onSubmit() {
await submit(this.dynamicForm(), async () => {
console.log('Dynamic form data:', this.dynamicForm().value());
return undefined;
});
}
}
Signal-Powered Form Arrays
No more clunky FormArray - just use reactive arrays:
@Component({
selector: 'app-contact-list',
template: `
<form>
<h2>Emergency Contacts</h2>
@for (contact of contactsData().contacts; track contact.id; let i = $index) {
<fieldset class="contact-group">
<legend>Contact {{ i + 1 }}</legend>
<input
[control]="contactsForm.contacts[i].name"
placeholder="Name"
/>
<input
[control]="contactsForm.contacts[i].phone"
placeholder="Phone"
type="tel"
/>
<button
type="button"
(click)="removeContact(i)"
[disabled]="contactsData().contacts.length <= 1"
>
Remove
</button>
</fieldset>
}
<button type="button" (click)="addContact()">Add Contact</button>
<button type="submit" (click)="onSubmit()">Save Contacts</button>
</form>
`
})
export class ContactListComponent {
contactsData = signal({
contacts: [
{ id: '1', name: '', phone: '' }
]
});
contactsForm = form(this.contactsData, (path) => [
// Validate each contact in the array
...this.contactsData().contacts.map((_, index) => [
required(path.contacts[index].name, {
message: 'Contact name is required'
}),
required(path.contacts[index].phone, {
message: 'Phone number is required'
}),
pattern(path.contacts[index].phone, /^\+?[\d\s-()]+$/, {
message: 'Invalid phone number format'
})
]).flat()
]);
addContact() {
this.contactsData.update(data => ({
...data,
contacts: [
...data.contacts,
{ id: crypto.randomUUID(), name: '', phone: '' }
]
}));
}
removeContact(index: number) {
this.contactsData.update(data => ({
...data,
contacts: data.contacts.filter((_, i) => i !== index)
}));
}
async onSubmit() {
await submit(this.contactsForm, async () => {
console.log('Contacts:', this.contactsForm.value());
return undefined;
});
}
}
// one other way to do like
import { form, FormArray, control } from '@angular/forms/signals';
import { signal } from '@angular/core';
export class SkillsFormComponent {
// Step 1: Create a signal for initial skills list
public skillsSignal = signal([ 'Angular', 'TypeScript' ]);
// Step 2: Create a FormArray from the signal
public skillsForm = new FormArray(
this.skillsSignal().map(skill => control(skill))
);
addSkill(newSkill: string) {
this.skillsForm.push(control(newSkill));
}
removeSkill(index: number) {
this.skillsForm.removeAt(index);
}
}
Available Form Properties
Signal Forms exposes several reactive properties you can subscribe to directly:
valid()- Whether the form is validinvalid()- Whether the form has validation errorspending()- Whether async validation is in progresstouched()- Whether any field has been toucheddirty()- Whether any field value has changederrors()- Array of validation errorssubmitting()- Whether form submission is in progresssubmitError()- Error information from failed submissionvalue()- Current form value
Cross-Field Validation (Password Confirmation)
Validating that two fields match (like a password and its confirmation) is a classic case. With signal forms, we can inspect the whole form state in a validator:
@Component({
// ...
})
export class ChangePasswordComponent {
passwordData = signal({ password: '', confirm: '' });
passwordForm = form(this.passwordData, (path) => [
required(path.password, { message: 'Password is required' }),
required(path.confirm, { message: 'Please confirm your password' }),
// Cross-field validator: ensure both fields match
validate(path, ({ value }) => {
if (value.password !== value.confirm) {
return [customError({
kind: 'mismatch',
message: 'Passwords do not match'
})];
}
return []; // no errors
})
]);
}
<form>
<label>
Password:
<input type="password" [control]="passwordForm.password" />
</label>
<label>
Confirm:
<input type="password" [control]="passwordForm.confirm" />
</label>
@if (passwordForm.errors().length) {
<div class="error">{{ passwordForm.errors()[0].message }}</div>
}
</form>
Asynchronous Validation
Signal forms support async validators out-of-the-box. For example, checking if a username is already taken:
@Component({
// ...
})
export class RegisterComponent {
userData = signal({ username: '' });
registrationForm = form(this.userData, (path) => [
required(path.username, { message: 'Username required' }),
// Async unique-check via HTTP
validateHttp(path.username, {
request: ({ value }) => {
return value() ? `https://example.com/api/check/${value()}` : undefined;
},
// Parse the response and return errors if any
errors: (res: any) => {
return res && !res.unique
? [customError({ kind: 'notUnique', message: 'Username already taken' })]
: [];
}
})
]);
}
<form>
<label>
Username:
<input [control]="registrationForm.username" />
</label>
@if (registrationForm.username.pending()) {
<div>Checking...</div>
}
@if (registrationForm.username.invalid()) {
<div>{{ registrationForm.username.errors()[0].message }}</div>
}
</form>
During the HTTP request, registrationForm.username.pending() is true, which we show as a "Checking..." indicator. The validateHttp function converts the HTTP call into a signal that updates once the fetch completes.
Dynamic Field Disabling
You can also disable fields dynamically based on form state:
@Component({
// ...
})
export class ProfileFormComponent {
profileData = signal({ firstName: '', lastName: '' });
profileForm = form(this.profileData, (path) => [
required(path.firstName, { message: 'First name is required' }),
required(path.lastName, { message: 'Last name is required' }),
// Disable lastName unless firstName is non-empty
disabled(path.lastName, ({ valueOf }) => valueOf(path.firstName) === '')
]);
}
<form>
<label>First Name: <input [control]="profileForm.firstName" /></label>
<label>Last Name: <input [control]="profileForm.lastName" /></label>
</form>
Testing Signal-Based Forms
Testing is crucial, and Signal-Based Forms make it more intuitive:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistrationComponent } from './registration.component';
describe('RegistrationComponent', () => {
let component: RegistrationComponent;
let fixture: ComponentFixture<RegistrationComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RegistrationComponent]
}).compileComponents();
fixture = TestBed.createComponent(RegistrationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe('Form Validation', () => {
it('should require first name', () => {
// Act
component.registrationForm.personalInfo.firstName.setValue('');
component.registrationForm.personalInfo.firstName.markAsTouched();
// Assert
expect(component.registrationForm.personalInfo.firstName.valid()).toBe(false);
expect(component.registrationForm.personalInfo.firstName.errors()).toContain(
jasmine.objectContaining({ message: 'First name is required' })
);
});
it('should validate conditional email requirement', () => {
// Arrange
component.userRegistration.update(data => ({
...data,
preferences: { ...data.preferences, notifications: true }
}));
// Act
component.registrationForm.personalInfo.email.setValue('');
// Assert
expect(component.registrationForm.personalInfo.email.valid()).toBe(false);
const errors = component.registrationForm.personalInfo.email.errors();
expect(errors.some(e => e.message === 'Email required when notifications are enabled')).toBe(true);
});
});
describe('Form Submission', () => {
it('should handle successful submission', async () => {
// Arrange
const validUserData = {
personalInfo: {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@company.com'
},
preferences: {
newsletter: true,
notifications: true
},
accountType: 'business' as const,
password: 'password123',
confirmPassword: 'password123'
};
component.userRegistration.set(validUserData);
spyOn(window, 'fetch').and.returnValue(
Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }))
);
// Act
await component.onSubmit();
// Assert
expect(window.fetch).toHaveBeenCalledWith('/api/users', jasmine.any(Object));
});
it('should handle submission errors with field mapping', async () => {
// Arrange
spyOn(window, 'fetch').and.returnValue(
Promise.resolve(new Response('{"error": "Email exists"}', { status: 400 }))
);
// Act
await component.onSubmit();
// Assert
expect(component.registrationForm.submitError()).toBeTruthy();
});
});
describe('Conditional Validation', () => {
it('should only require business email format when newsletter is enabled', () => {
// Arrange - enable newsletter
component.userRegistration.update(data => ({
...data,
preferences: { ...data.preferences, newsletter: true }
}));
// Act - set gmail address
component.registrationForm.personalInfo.email.setValue('test@gmail.com');
// Assert - should fail business email validation
const errors = component.registrationForm.personalInfo.email.errors();
expect(errors.some(e => e.message.includes('Business email required'))).toBe(true);
});
it('should accept gmail when newsletter is disabled', () => {
// Arrange - disable newsletter
component.userRegistration.update(data => ({
...data,
preferences: { ...data.preferences, newsletter: false }
}));
// Act - set gmail address
component.registrationForm.personalInfo.email.setValue('test@gmail.com');
// Assert - should pass validation
const errors = component.registrationForm.personalInfo.email.errors();
expect(errors.some(e => e.message.includes('Business email required'))).toBe(false);
});
});
});
Performance Considerations and Best Practices
Signal-Based Forms are built with performance in mind, but there are still best practices to follow:
1. Use Computed Signals for Derived State
export class ProfileComponent {
user = signal({
firstName: '',
lastName: '',
email: '',
birthDate: ''
});
profileForm = form(this.user, (path) => [
apply(path.firstName, nameSchema),
apply(path.lastName, nameSchema),
apply(path.email, emailSchema)
]);
// Derived state using computed signals
fullName = computed(() => {
const user = this.user();
return `${user.firstName} ${user.lastName}`.trim();
});
isAdult = computed(() => {
const birthDate = new Date(this.user().birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return age >= 18;
});
// Conditional validation based on computed state
ageRestrictedForm = form(this.user, (path) => [
required(path.email, {
message: 'Email required for adult accounts',
when: () => this.isAdult()
})
]);
}
2. Debounce Expensive Validations
import { debounce } from 'lodash-es';
const expensiveAsyncValidator = debounce(async (value: string) => {
if (!value) return null;
const result = await this.expensiveValidationService.validate(value);
return result.isValid ? null : {
expensive: { message: result.errorMessage }
};
}, 500);
// Use in form validation
registrationForm = form(this.userRegistration, (path) => [
required(path.personalInfo.email, { message: 'Email is required' }),
validateAsync(path.personalInfo.email, expensiveAsyncValidator)
]);
3. Use Schema Functions for Reusable Validation Logic
// validators/schemas.ts
import { schema, apply } from '@angular/forms/signals';
export const personNameSchema = schema<string>((path) => [
required(path, { message: 'This field is required' }),
minLength(path, 2, { message: 'Must be at least 2 characters' }),
pattern(path, /^[a-zA-Z\s'-]+$/, {
message: 'Only letters, spaces, hyphens and apostrophes allowed'
})
]);
export const strongPasswordSchema = schema<string>((path) => [
required(path, { message: 'Password is required' }),
minLength(path, 8, { message: 'Password must be at least 8 characters' }),
pattern(path, /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'Password must contain uppercase, lowercase, and number'
})
]);
// In your component
registrationForm = form(this.userRegistration, (path) => [
apply(path.personalInfo.firstName, personNameSchema),
apply(path.personalInfo.lastName, personNameSchema),
apply(path.password, strongPasswordSchema)
]);
When to Choose Signal-Based Forms
Here's my decision framework for when to adopt Signal-Based Forms:
Choose Signal-Based Forms when:
Building new applications or features
Working with complex conditional validation
Need fine-grained reactivity
Want to reduce boilerplate code
Team is comfortable with modern Angular patterns
Working with dynamic forms
Stick with Reactive Forms when:
Working with legacy codebases
Need battle-tested stability for critical applications
Team prefers traditional approaches
Using third-party form libraries that expect Reactive Forms
Tight project deadlines (experimental features carry risk)
Avoid Template-Driven Forms when:
Building anything beyond simple contact forms
Need complex validation logic
Want type safety
Working on enterprise applications
Production-Ready Patterns
Here are some patterns for using Signal-Based Forms in production:
1. Create Form Factories for Complex Forms
@Injectable({
providedIn: 'root'
})
export class FormFactoryService {
createUserRegistrationForm(initialData?: Partial<UserRegistration>) {
const defaultData: UserRegistration = {
personalInfo: { firstName: '', lastName: '', email: '' },
preferences: { newsletter: false, notifications: true },
accountType: 'personal',
password: '',
confirmPassword: ''
};
const userData = signal({ ...defaultData, ...initialData });
return form(userData, (path) => [
apply(path.personalInfo.firstName, personNameSchema),
apply(path.personalInfo.lastName, personNameSchema),
apply(path.personalInfo.email, emailSchema),
required(path.personalInfo.email, {
message: 'Email required when notifications are enabled',
when: ({ valueOf }) => valueOf(path.preferences.notifications) === true
}),
apply(path.password, strongPasswordSchema),
required(path.confirmPassword, {
message: 'Please confirm your password',
when: ({ valueOf }) => valueOf(path.password) !== ''
})
]);
}
}
2. Handle Server-Side Validation Errors
async onSubmit() {
await submit(this.registrationForm, async () => {
try {
const response = await this.userService.register(this.registrationForm.value());
return undefined; // Success
} catch (error) {
if (error instanceof HttpErrorResponse && error.status === 400) {
// Map server validation errors to form fields
const serverErrors = error.error.errors;
return Object.keys(serverErrors).map(field => ({
kind: 'server' as const,
message: serverErrors[field][0],
field: this.getFormFieldPath(field)
}));
}
return [
{
kind: 'server' as const,
message: 'Registration failed. Please try again.'
}
];
}
});
}
private getFormFieldPath(serverField: string) {
const fieldMapping: { [key: string]: any } = {
'first_name': this.registrationForm.personalInfo.firstName,
'last_name': this.registrationForm.personalInfo.lastName,
'email': this.registrationForm.personalInfo.email
};
return fieldMapping[serverField];
}
3. Create Reusable Form Field Components
@Component({
selector: 'app-form-field',
template: `
<div class="form-field" [class.has-error]="hasError()">
<label [for]="fieldId()">{{ label() }}</label>
<ng-content></ng-content>
@if (hasError() && showErrors()) {
@for (error of control().errors(); track error.code) {
<span class="error-message">{{ error.message }}</span>
}
}
</div>
`,
styles: [`
.form-field { margin-bottom: 1rem; }
.form-field.has-error input, .form-field.has-error select {
border-color: #e53e3e;
}
.error-message {
color: #e53e3e;
font-size: 0.875rem;
display: block;
margin-top: 0.25rem;
}
`]
})
export class FormFieldComponent {
label = input.required<string>();
fieldId = input.required<string>();
control = input.required<any>();
showErrors = input(signal(true));
hasError = computed(() =>
this.control()?.errors()?.length > 0 &&
(this.control()?.touched() || this.showErrors())
);
}
A tree of Field
Calling the form function gives the developer access to a Field tree. The form itself is a Field called the Root Field.
A Field instance provides its state, which later allows you to retrieve its value, validity, and more and it can be retrieve by calling the Field function.
Let's illustrate that with a bit of code :)
interface Assigned {
name: string;
firstname: string;
}
interface Todo {
title: string;
description: string;
status: TodoStatus;
assigned: Assigned[]
}
@Component({
selector: 'app-form',
templateUrl: './app-form.html'
})
export class AppForm {
todoModel = signal<Todo>({
title: '',
description: '',
status: 'not_begin',
assigned: []
}); // We create the model that will be the source of truth for the form and it's tree field
todoForm = form(this.todoModel); // we create the form which is linked to the model
titleField: Field<string> = this.todoForm.title;
firstAssigned: Field<Assigned> = this.todoForm.assigned[0];
firstAssignedName: Field<string> = firstAssigned.name;
}
Field Instance
As explained previously, a Field instance returns the state of that field. The state is composed of 6 main points.
value: A WritableSignal that allows you to read and write the value of a field.
errors: A signal for retrieving validation errors on the field.
valid: A signal for retrieving the field's validity.
disabled: A signal for retrieving whether the field is disabled.
touched: A signal to know if the user has interacted with the field or one of its children.
dirty: A signal to know if the field or one of its children is dirty.
For more details on what a field instance can offer, or to see the concrete implementation, please refer to the following
What's Coming Next in Angular Signal Forms?
The Angular team is actively working on Signal-Based Forms, and here's what we might see in future releases:
JSON Schema Integration โ Generate forms from JSON schemas automatically
Better DevTools Integration โ Debug signal forms visually
Performance Optimizations โ Even faster updates for large forms
More Built-in Validators โ Common validation patterns out of the box
Enhanced Accessibility โ ARIA attributes and screen reader support
Form State Persistence โ Automatic save/restore of form data
Keep an eye on the Angular blog and GitHub discussions for the latest updates!
Recap: Your Signal-Based Forms Journey
We've covered a lot of ground! Here's what you now know:
Why signals matter for forms: reduced boilerplate, better reactivity, cleaner code
Core APIs:
form(),schema(),apply(),submit(), and conditional validation withwhenReal-world implementation with complex validation scenarios
Testing strategies that work with signal-based architecture
Performance patterns for production applications
Dynamic form creation with runtime control management
Advanced patterns like form arrays without
FormArray
The key takeaway? Signal-Based Forms aren't just a new APIโthey represent a fundamental shift toward more declarative, reactive form handling. They eliminate much of the ceremony around form creation while providing powerful features for complex scenarios.
Remember: this is still experimental technology. Start with small experiments, learn the patterns, and be prepared for API changes as the feature evolves toward stable release.
What Did You Think?
I'm curious about your experience with Angular forms and your thoughts on this new approach. Have you hit similar frustrations with traditional form handling?
Drop a comment below and let me know:
Your biggest form-related pain point in current projects
Whether you're planning to experiment with Signal-Based Forms
Which pattern from this article you're most excited to try
Any specific scenarios you'd like me to explore in future articles
Your feedback helps me understand what resonates most with the Angular community!
Found This Helpful?
If this deep-dive saved you some research time or gave you new ideas for handling forms, hit that clap button! Your engagement helps other developers discover content like this.
I especially love hearing success storiesโif you try any of these patterns in your projects, come back and share how it went.
Want More Angular Insights?
I regularly share advanced Angular patterns, performance tips, and deep-dives into new features. Follow me here on Medium to get notified when I publish:
Migration guides for new Angular features
Performance optimization techniques that actually work
Testing strategies for modern Angular applications
Real-world patterns from production applications
What Angular topic should I tackle next? I'm always looking for community input on what would be most valuable!
Action Points for Getting Started
Ready to experiment with Signal-Based Forms? Here's your roadmap:
This Week:
Set up a new Angular project with the latest version
Enable the experimental Signal Forms feature
Build a simple login form to get familiar with the syntax
Next Week:
Convert one existing Reactive Form to Signal-Based approach
Experiment with conditional validation using
whenTry the
schema()andapply()patterns for reusable validation
This Month:
Build a complex form with dynamic fields
Implement the
submit()function with proper error handlingWrite comprehensive tests for your signal-based forms
Share your learnings with your team
Ongoing:
Follow Angular's GitHub discussions for API updates
Experiment with advanced patterns as your confidence grows
Consider Signal-Based Forms for new feature development
Provide feedback to the Angular team on the experimental APIs
Remember: every expert was once a beginner. The best way to learn these new patterns is by building real applications. Start small, experiment often, and don't be afraid to make mistakesโthat's how we all grow as developers!
The purpose of the article is to make you aware of signal form; it may have broken code, or something may not work.
References:
https://javascript.plainenglish.io/angular-signal-forms-are-out-experimentally-4257782191bb
๐ Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
๐ผ LinkedIn โ Letโs connect professionally
๐ฅ Threads โ Short-form frontend insights
๐ฆ X (Twitter) โ Developer banter + code snippets
๐ฅ BlueSky โ Stay up to date on frontend trends
๐ GitHub Projects โ Explore code in action
๐ Website โ Everything in one place
๐ Medium Blog โ Long-form content and deep-dives
๐ฌ Dev Blog โ Free Long-form content and deep-dives
โ๏ธ Substack โ Weekly frontend stories & curated resources
๐งฉ Portfolio โ Projects, talks, and recognitions
โ๏ธ Hashnode โ Developer blog posts & tech discussions
๐ If you found this article valuable:
Leave a ๐ Clap
Drop a ๐ฌ Comment
Hit ๐ Follow for more weekly frontend insights
Letโs build cleaner, faster, and smarter web apps โ together.
Stay tuned for more Angular tips, patterns, and performance tricks! ๐งช๐ง ๐
โจ Share Your Thoughts To ๐ฃ Set Your Notification Preference






