Bringing the power of Signals to Angular Forms with Signal Forms
Tim Deschryver
timdeschryver.dev
When the Angular teams introduced signals I needed a playing ground to get familiar with it, so I decided to use signals internally to build a new API for Angular forms.
It was then that I realized that using signals to create forms is a good fit that provides plenty of flexibility and a good experience.
These signal driven forms, take the best out of Template Driven Forms and Reactive Forms with a sprinkle of signals on top of it.
I am writing this blog post to share an idea, in which the implementation is still a work in progress.
If you're a fan of the idea, feel free to try it out, provide feedback, or help with the implementation.
Signal forms are built on top of the existing battle-tested functionality.
On the template side, it uses the power and the convenience of ngModel.
Using this technique the template model and the domain model can stay in sync.
For the typescript side, signal forms have a similar API to reactive forms (with a few additional utility functions), and replaces RxJS with signals.
Because signal form uses a similar API, I think it should be simple to transition from Reactive forms to signal forms if you're interested and motivated.
Let's take a look at the code to give you an idea of what this looks like.
A signal form is built using the createFormField and createFormGroup methods.
The most simple example is the form field FormField, which is created using createFormField.
import {createFormField} from 'ng-signal-forms';
exportclassAppComponent{
// implicit type
name=createFormField('');
// or be explicit about the type
name=createFormField<string>('');
}
The first argument of createFormField accepts the initial value of the field.
The initial value is used to implicitly infer its type.
The above example creates a FormField<string> that contains the current value of a field and the current state of the control.
To render/bind the field to an HTML element, use the ngModel attribute in combination with the formField attribute.
To the latter, you pass the form field instance.
As mentioned earlier the formField attribute is the glue between the domain model and the HTML element, which keeps the value and state in sync.
This results in the HTML element getting the appropriate classes and attributes, reflecting the internal state of the form field.
A signal field sets the following classes:
validity: ng-valid, ng-invalid, and ng-pending (more on this subject later)
user interaction: ng-untouched and ng-touched
value changes: ng-pristine and ng-dirty
Notice that Signal Forms follows the convention of the official Angular forms.
Signal Forms are also able to set the following attributes (more on this later).
This is different from Angular Forms, and this is useful to reflect the internal state of the HTML element, which can improve the experience:
For simple forms without additional options/configurations, the form fields can also be created in a shorter version.
Instead of using createFormField, the initial value can be provided directly.
import {createFormGroup} from 'ng-signal-forms';
exportclassAppComponent{
formModel=createFormGroup({
name:'',
bibNumber:undefined,
});
}
To use the formModel in the HTML template, access the form fields using the controls property of the form model.
Once you're familiar with form groups, you can also nest form groups.
Using the createFormGroup method, you can create a nested form group.
On the HTML side, use the controls property to drill down into the nested form group.
import {createFormField,createFormGroup} from 'ng-signal-forms';
Besides an object, the createFormGroup method also accepts an array of form fields (or form groups).
To render the form fields in the HTML template, use the controls method to iterate over the form fields and bind each control to an input element.
import {createFormField,createFormGroup} from 'ng-signal-forms';
Form groups use identical states as form fields.
Except, form groups do not interact with the HTML element directly, in other words, they do not set classes or attributes on the HTML element.
The state of a form group is derived (computed) from the state of its form fields.
If one field is dirty, the form group's state is set to dirty.
If one field is touched, the form group's state is set to touched.
The exception is the valid state, which is derived from its own validity and the validity of its form fields.
A form group does not have disabled or read-only states, because these states are not applicable to a form group.
A form is useless without validation.
Signal forms provide a set of default validators, which can be used to validate form fields and groups.
To register a validator, pass an array of validators to the form field options.
The default validators are available via the V export.
How you can create a custom validator will be mentioned in the future.
To render the validation error to the user, retrieve the error using the hasError method of the form control.
The example below also makes use of the touched method to only show the error when the user has interacted with the field.
import {createFormField,createFormGroup,V} from 'ng-signal-forms';
To show a validation message to the user, the error message can be provided to the validator.
This is often easier to set at this point instead of doing this in the HTML template, it also allows you to reuse messages in different forms.
To add a message to a validator, pass an object to the validator where the message property is set to a function that returns the message.
The function receives the validator's configuration as an argument, which can be used to create a dynamic message.
The validation message can be retrieved using the errorMessage method of the form control.
import {createFormField,createFormGroup,V} from 'ng-signal-forms';
exportclassAppComponent{
formModel=createFormGroup({
name:createFormField('',{
validators: [
V.required(),
{
validator:V.minLength(2),
message:({minLength})=>`Name must be at least ${minLength} characters`,
In some cases, the validation of a field depends on the value of another field.
This is where in my eyes, signal forms shine.
For example, a password confirmation field should be valid when it matches the password field.
To create a validator that depends on another field, use the value signal to react to value changes of the other field.
Because the value of the other field is a signal, the validator is automatically re-evaluated when the value of the other field changes.
The easiest way to achieve this is to create a form group, and reference to form field instances.
On the HTML side, everything remains the same (the validation messages are left out for brevity).
import {createFormField,createFormGroup,V} from 'ng-signal-forms';
We've seen how to reference other fields within a validator.
Using the same technique, you can also disable validators based on the value of another field or source.
To disable a validator, use the disabled property of the validator configuration.
import {createFormField,createFormGroup,V} from 'ng-signal-forms';
In the Disable validators example we've seen how to disable validators based on the value of another field.
To create a better user experience, Signal Forms also keeps track of the "hidden" state of controls, which can be used to hide HTML elements.
To hide a form field, set the hidden property of the form field options.
Just like any other configuration, we can use signals to reactively hide the form field based on other signal sources, in this case, a form field.
import {createFormField,createFormGroup,V} from 'ng-signal-forms';
In FormField<T> States we've seen that form fields automatically set the disabled and readonly attributes on the HTML element.
But we haven't seen how we can accomplish this on the form's side.
This is getting a bit boring because I keep repeating myself, but to set the disabled and readonly state of a form field, use the disabled and readonly properties of the form field options.
Because the html element is coupled to the form field, the html element will automatically reflect the state of the form field.
import {createFormField,createFormGroup,V} from 'ng-signal-forms';
This is definitely an area that needs to be improved.
Currently, it's not easy to update a form group... but you're able to set the value of an individual form field.
Because we're working with signals we can use the methods set and update (of the signals) to update the value of the form field.
Because Signal Forms is built on top of the existing NgModel, it's possible to reuse features in combination with Signal Forms.
For example, the ngModelOptions attribute can be used to set the update strategy of the form field, in the example below the form's field value is updated on blur.
Signal Forms provides a new way to create forms in Angular.
It's built on top of the existing NgModel with a similar API to ReactiveForms, and uses signals to create a reactive and flexible form.
Powered by the reactive nature of signals, the goal is to create complex forms with ease.
Signals are a good fit for this because of the reactive mechanism.
The form can react to value changes to trigger the affected validators.
The idea behind Signal Forms is that all the logic is included within the form model.
That's why the extra states such as hidden, disabled, and readonly are added.
These can also easily react to signal changes to enable/disable their value.
The HTML is only responsible to render the current state of the form.
The result is a good-looking, flexible, and reactive form.
Take a look the complete code that is used in this blog post to create the form example above.
If you want to play around with this example, take a look at GitHub repository.
import {createFormField,createFormGroup,SignalInputDirective,V} from 'ng-signal-forms';
@Component({
selector:'app-root',
standalone:true,
imports: [
RouterOutlet,
FormsModule,
JsonPipe,
HlmInputDirective,
HlmButtonDirective,
HlmLabelDirective,
SignalInputDirective,
HlmAlertDirective,
HlmIconComponent,
HlmAlertIconDirective,
HlmAlertTitleDirective,
HlmAlertDescriptionDirective,
AppErrorComponent,
HlmSeparatorDirective,
BrnSeparatorComponent,
HlmButtonDirective,
HlmCheckboxComponent,
HlmCardDirective,
HlmCardHeaderDirective,
HlmCardTitleDirective,
HlmCardDescriptionDirective,
HlmCardContentDirective,
HlmCardFooterDirective,
HlmCheckboxComponent,
HlmSwitchComponent,
HlmButtonDirective,
],
templateUrl:'./app.component.html',
styleUrl:'./app.component.css',
host:{
class:'text-foreground block antialiased',
},
})
exportclassAppComponent{
protectedreadonlyBibNumber=signal(false);
protectedformModel=createFormGroup({
name:createFormField('',{
validators: [
V.required(),
{
validator:V.minLength(2),
message:({minLength})=>`Name must be at least ${minLength} characters`,
Signal Forms is still a work in progress, any feedback and help with the implementation is more than welcome.
There are still some rough edges that need to be ironed out, and some features that need to be added.
If you're interested in contributing, use GitHub, the comments below, or simply send me a message.
I think that Signal Forms is a good fit for Angular, and I'm excited to see where this will go.
Feel free to update this blog post on GitHub, thanks in advance!
Join My Newsletter (WIP)
Join my weekly newsletter to receive my latest blog posts and bits, directly in your inbox.
Support me
I appreciate it if you would support me if have you enjoyed this post and found it useful, thank
you in advance.