Signal-based forms are one of the coolest new additions to Angular’s ecosystem.
Let’s walk through what the sample login form above is doing and why it’s nice to work with.
1. Modeling the form with signals
First, we define a simple data model:
type LoginData = {
email: string;
password: string;
};
Then we create a signal that holds that model:
loginModel = signal<LoginData>({
email: '',
password: '',
});
This signal is our single source of truth for the form’s state. Instead of separate controls, we bind the whole object and let the form() helper take care of wiring it up.
2. Creating a signal form
loginForm = form(this.loginModel, (login) => {
required(login.email, { message: 'Email is required' });
email(login.email, { message: 'Enter a valid email address' });
required(login.password, { message: 'Password is required' });
pattern(
login.password,
/^((?=\S*?[A-Z])(?=\S*?[a-z])(?=\S*?[0-9]).{6,})\S$/,
{
message:
'Password must be at least 6 characters long and include at least one uppercase letter, one lowercase letter, and one number, with no spaces.',
}
);
});
What’s going on here?
Each field now knows:
3. Binding to the template with and [field]
In the template, we don’t deal with [(ngModel)] or formControlName. Instead, we use the Field standalone component and bind the field signal:
type="email" [field]="loginForm.email" />
Let’s walk through what the sample login form above is doing and why it’s nice to work with.
1. Modeling the form with signals
First, we define a simple data model:
type LoginData = {
email: string;
password: string;
};
Then we create a signal that holds that model:
loginModel = signal<LoginData>({
email: '',
password: '',
});
This signal is our single source of truth for the form’s state. Instead of separate controls, we bind the whole object and let the form() helper take care of wiring it up.
2. Creating a signal form
loginForm = form(this.loginModel, (login) => {
required(login.email, { message: 'Email is required' });
email(login.email, { message: 'Enter a valid email address' });
required(login.password, { message: 'Password is required' });
pattern(
login.password,
/^((?=\S*?[A-Z])(?=\S*?[a-z])(?=\S*?[0-9]).{6,})\S$/,
{
message:
'Password must be at least 6 characters long and include at least one uppercase letter, one lowercase letter, and one number, with no spaces.',
}
);
});
What’s going on here?
- form(this.loginModel, …) creates a signal form bound to the loginModel signal.
- Inside the callback, login exposes field-level signals like login.email and login.password.
- We attach validation rules using helpers like:
- required(field, { message })
- email(field, { message })
- pattern(field, regex, { message })
Each field now knows:
- Its current value (as a signal)
- Its validation status
- Its list of errors (if any)
3. Binding to the template with and [field]
In the template, we don’t deal with [(ngModel)] or formControlName. Instead, we use the Field standalone component and bind the field signal:
type="email" [field]="loginForm.email" />