The Future of Angular Forms is Here, and It's Powered by Signals
⚠️ 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 validators -
Conditional validation: We use
when: ({ valueOf })
for dynamic validation -
New control flow syntax: We use
@if
and@for
instead ofngIf
andngFor
-
Built-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 progress -
submitError()
→ contains error information if submission fails -
submitSuccess()
→ 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 valid -
invalid()
- Whether the form has validation errors -
pending()
- Whether async validation is in progress -
touched()
- Whether any field has been touched -
dirty()
- Whether any field value has changed -
errors()
- Array of validation errors -
submitting()
- Whether form submission is in progress -
submitError()
- Error information from failed submission -
value()
- 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 withwhen
- Real-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
when
- Try the
schema()
andapply()
patterns for reusable validation
This Month:
- Build a complex form with dynamic fields
- Implement the
submit()
function with proper error handling - Write 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
🎯 Your Turn, Devs!
👀 Did this article spark new ideas or help solve a real problem?
💬 I'd love to hear about it!
✅ Are you already using this technique in your Angular or frontend project?
🧠 Got questions, doubts, or your own twist on the approach?
Drop them in the comments below — let’s learn together!
🙌 Let’s Grow Together!
If this article added value to your dev journey:
🔁 Share it with your team, tech friends, or community — you never know who might need it right now.
📌 Save it for later and revisit as a quick reference.
🚀 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! 🧪🧠🚀
Top comments (0)