Experimental - This is intended for use in non-production applications, as the API can change (without notice) as it did with 21.0.0-next.8 where Control was renamed to Field.
Signal Forms is one of the most talked-about features to be added to the Angular framework, and we recently got to see an experimental version of this with the recent beta release of Angular version 21. Signal Forms will be a game-changer when it comes to developing forms. Not only does it simplify form creation by removing a significant amount of boilerplate code that Template and Reactive Forms require, but it has also made form validation, form submission and creating custom controls significantly easier.
Signal Forms are model driven, we create a signal model and then pass this to the form function as an argument.
protected readonly userProfile = signal<UserProfile>({
// model properties
})
For example, in the past when creating custom controls that will be used in our forms, we've had to implement the ControlValueAccessor interface to allow our custom controls to integrate with the Forms or Reactive Forms Module, the good news is this has been greatly simplified as well, along with a few other issues we faced when creating custom controls, which we'll look at in this post.
Let's walk through setting up a Signal Form, we start off by creating our model.
type UserProfile = {
firstName:string;
lastName:string;
phone:string;
email:string;
}
Next, let's build our form, we create a signal of our model and then using the new form function to create a wrapper around our model.
export class User {
protected readonly userProfile = signal<UserProfile>({
firstName:'',
lastName:'',
phone:'',
email:'',
})
protected readonly userForm = form(this.userProfile);
}
That's all we need to create a Signal Forms - how easy was that compared to Template or Reactive forms.
Signal Forms are represented as a FieldTreeand FieldState this is a hierarchical structure of our form and looks like this.
// our model
type UserProfile = {
firstName:string;
lastName:string;
phone:string;
email:string;
}
// user (FieldTree root)
// ├─ firstName (FieldState)
// ├─ lastName (FieldState)
// ├─ phone (FieldState)
// └─ email (FieldState)
If we had a nested model it would be represented like this:
// our model
type userProfile = {
firstName: string;
lastName: string;
phone: string;
email: string;
address: {
street: string;
city: string;
}
}
// user (FieldTree root)
// ├─ firstName (FieldState)
// ├─ lastName (FieldState)
// ├─ phone (FieldState)
// ├─ email (FieldState)
// └─ address (FieldTree node)
// ├─ street (FieldState)
// └─ city (FieldState)
The main difference of Signal Forms compared to Template or Reactive forms is that Signal Forms doesn't maintain a copy of the data, so when we update a FieldState in the tree, we are directly mutating the original model.
A FieldState represents an individual form field including it's state (value, validity, dirty status etc...).
Now, let's have a look at connecting our input components to our new form. I'm using Angular Material in these examples.
<mat-form-field>
<mat-label for="firstName">First name</mat-label>
<input
[field]="userForm.firstName"
id="firstName"
matInput
type="text"
placeholder="First name"
/>
</mat-form-field>
To connect our model and template, we need to use the new [field] method, passing in a field that we want this input to be bound to. This is nice and simple, and we get two-way data binding out of the box.
In version 21.0.0-next.8 [control] was renamed to [field].
// for reference - the array for the dropdown to iterate over
// address: [] = [
// { value: '0', viewValue: 'Primary' },
// { value: '1', viewValue: 'Billing' },
// { value: '2', viewValue: 'Shipping' },
// ];
<mat-form-field>
<mat-label for="address">Address</mat-label>
<mat-select id="address" [field]="userForm.address">
@for (addr of address ; track address.value() ) {
<mat-option [value]="addr.value">
{{addr.viewValue}}
</mat-option>
}
</mat-select>
</mat-form-field>
Setting up a drop-down list is just as easy.
Let's now look at validation. The form function takes a second parameter, which can be a schema or a function, or form options (if a schema is passed as the second argument, the form options can be passed in as a third option).
// recap what our model looks like
type UserProfile = {
firstName:string;
lastName:string;
phone:string;
email:string;
}
protected readonly userForm = form(this.userProfile, (path)=>{
required(path.firstName),
required(path.lastName),
email(path.email)
});
This second parameter is a function and takes a fieldPath as an argument, in this function we set up our validation.
The ordering in which validation is applied doesn't matter.
The built-in validation is now imported from forms/signals and we have a similar list two what we have in Template or Reactive Forms:
- Max
- MaxLength
- Min
- MinLength
- Pattern
- Required
With the validation, we set the path to the fieldState we want the validation applied to. In the HTML we just need to iterate over the errors object.
<mat-form-field>
<mat-label for="email">Email address</mat-label>
<input
id="email"
type="email"
matInput
[field]="profileForm.email"
placeholder="Email"
required
/>
@if(profileForm.email().errors().length > 0) {
<mat-error>
@for(error of profileForm.email().errors(); track error) {
<div>
Error message goes here...
</div>
}
</mat-error>
}
</mat-form-field>
Adding error messages like this could get a bit long with multiple error messages, so to help with this, we can add a message to the form like this:
// recap what our model looks like
type UserProfile = {
firstName:string;
lastName:string;
phone:string;
email:string;
}
protected readonly userForm = form(this.userProfile, (path)=>{
required(path.firstName, {message: 'This is a required field.'}),
required(path.lastName, {message: 'This is a required field.'}),
email(path.email, {message: 'The email address is not valid.'})
});
And in the HTML we can read the error.message like this:
@if(profileForm.email().errors().length > 0) {
<mat-error>
@for(error of profileForm.email().errors(); track error) {
<span>{{ error.message }}</span>
}
</mat-error>
}
Because I'm using Angular Material (and this might just because it's still experimental at present), but to get the mat-error to work and display the error message correctly under the input I had to wrap an @if block around the mat-error, hopefully this will not be the case in later releases but is necessary at the moment.
I don't like repeating code unnecessarily, and the validation we have just created repeats (albeit, just twice in our example), but imagine if you have three, six, nine, or more controls; the that's going to be repeated a lot. Luckily, there is another method that we can use to remove the duplication. For this, we need to create a schema, which can then be applied to our form. Let's adjust our code and create a schema.
const profileSchema: Schema<string> = schema((path) => {
required(path, { message: 'This is a required field.' });
minLength(path, 3, { message: 'This needs to be more than three characters'});
});
protected readonly userForm = form(this.userProfile, (path)=>{
apply(path.firstName, profileSchema);
apply(path.lastName, profileSchema );
email(path.email, {message: 'The email address is not valid.'})
});
We use the apply function to apply our schema to the fields we want to validate. This will apply both required and minLength to the firstName, and we can create multiple schemas as necessary, for example we only want minLength validation on certain controls.
Custom validation
We can also create custom validators as well, let's create a custom validator that makes the phone number control contains numbers only. We need to create a function that takes a path and an optional options
export function numericOnly(
path: FieldPath<string>,
options?: { message?: string }
): void {
validate(path, (ctx) => {
const value = ctx.value();
if (!/^\d+$/.test(String(value))) {
return customError({
kind: 'phone',
value: true,
message: options?.message || 'Phone must contain only numbers.',
});
}
return customError({
kind: 'phone',
value,
});
});
protected readonly userForm = form(this.userProfile, (path)=>{
// other validators
numericOnly(path.phone);
});
Conditional validation
Signal Forms has you covered for that as well. Let's adjust our model, lets add a emailMarketing flag to our model, so that the validation for email is only applied if the emailMarketing checkbox is ticked (true).
type UserProfile = {
firstName:string;
lastName:string;
phone:string;
email:string;
emailMarketing: boolean;
}
protected readonly userForm = form(this.userProfile, (path)=>{
required(path.email, {
when: ({ valueOf }) => valueOf(path.emailMarketing) === true,
message: 'This is a required field.',
});
email(path.email, {message: 'The email address is not valid.'})
});
We'll add a required validator and set a path to email, in the configuration options there is a when property and we can use this to check the valueOf another field, in our case we what to apply the validation when the emailMarketing checkbox is ticked.
If you have applied a required validation schema you will need to remove this as it will also be applied.
Form Submission
When it comes to submitting our form to the server, we have a new function called... (you've guessed it) submit. This function takes two arguments: the first is our form and the second is a function that returns a promise or undefined if the save to our back end is successful. If it's not successful, we return an array of objects, and within this object, we can set the kind of error here; we're specifying server , if we want to attach the error to a specific control, we specify the field; and finally, we set the error message to be displayed.
onSubmit() {
submit(this.userProfile, async (form) => {
try {
this.userProfileService.saveForm(form); // call to API to save our form data
this.userProfile().reset();
return undefined;
} catch (error) {
return [
{
kind: 'server',
field: this.profileForm.firstName,
message: (error as Error).message,
},
];
}
});
}
Calling the .reset() on the form only resets the pristine, dirty and touched to reset the form values after submitting reset the model values.
When submitting a form, we usually disable the save button while this operation takes place. The form() function exposes a submitting() signal that we can use to disable our save button.
<button
[disabled]="!profileForm().valid() || profileForm().submitting()"
(click)="submit()">
matFab
extended
class="toggle-btn"
type="button"
Save
</button>
In Reactive forms we would set up our forms like: <form (ngSubmit)="submit($event)"> ... </form> at present this isn't fully fleshed out in Signal Forms, there is a some information on the road-map about it and possible solutions.
Custom controls
When creating custom controls we no longer need to implement the ControlValueAccessor interface, we now have a simpler new interface, the good news is we only need to implement one property and not four methods as before, this new interface is called FormValueControl<> and we need to set the value property our component (and it must be called value), which must be a model(). Let's create an example:
// our component
import { Component, model } from '@angular/core';
import { FormValueControl } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'star-rating',
imports: [MatIconModule],
template: `
@if(required()){
<span class="required-asterisk">*</span>
}
<div class="star-rating">
@for (star of stars; track $index) {
<mat-icon
class="star"
[class.filled]="star <= value()"
(click)="setRating(star)"
(mouseenter)="!disabled() && (hoverRating = star)"
(mouseleave)="hoverRating = 0">
{{ (hoverRating >= star || value() >= star) ? 'star' : 'star_border' }}
</mat-icon>
}
</div>
`,
})
export class StarRatingComponent implements FormValueControl<number> {
value = model(0);
disabled = input(false);
required = input(false);
stars = [1, 2, 3, 4, 5];
hoverRating = 0;
setRating(rating: number) {
if (!this.disabled()) {
this.value.set(rating);
}
}
}
With the new FormValueControl interface, we just need to include the value property in our component; it also has to be of type modelSignal, and that's all we need to set. If we look at the FormValueControl interface we can see that it extends FormUiControl this provides many optional properties for instance, required and disabled and for our component to make use of these we just need to include them in our component and in the parent component we just need to set the states in the form.
<star-rating [field]="form.starRating" />
type ProductProfile = {
name:string;
description:string;
price:number;
rating:number;
leaveReview: boolean;
}
protected readonly productProfile = signal<ProductProfile>({
name:'',
description:'',
price:0,
starRating:0,
leaveReview: false
})
protected readonly productForm = form(this.productProfile, (path) => {
required(path.starRating),
disabled(path.starRating,({valueOf})=> valueOf(path.leaveReview) === true)
});
This is all we need to have the required and disabled properties for our form to work as we'd expect, the starRating is in a disabled state until the leaveReview is set to true.
Conclusion
Signal Forms is going to be a game-changer for creating forms in Angular applications when it's released. Hopefully, this post has given some insight into how to use it. I've been really impressed by how complete it is (even though it's currently experimental), and over the next few weeks and months, this will only get better.

