Refactoring a form to a Signal Form


In this blog post we will refactor an existing Angular form to use the new Signal Forms feature, which will be introduced in Angular 21 as an experimental feature. This won't be a detailed introduction to Signal Forms, for that I highly recommend looking at the Extra Resources section at the end of this post, these resources have helped me during the refactoring process.
Signal forms is a new way to build forms in Angular, it leverages signals to provide a more declarative and efficient way to manage form state and validation. Compared to the already existing Template Driven Forms and Reactive Forms, Signal Forms leans more towards the Reactive Forms approach, but uses Signals instead of Observables. This change in underlying technology allows for a more seamless integration with Angular's reactivity model and change detection. With the addition of Signal Forms, the Angular team took the opportunity to rethink and redesign the forms API.
The example form is a small form with a first and last name field, both required. It also consists of two optional address groups (for billing and shipping), which are only shown when the corresponding checkbox is checked. Putting my best designer hat on, the form looks like this:

Creating the Form link
Because I'm #TeamTemplateDrivenForms, the original form was built using that approach. This means that the form is mostly defined within the HTML template, including the validation rules. Only the model is defined on the TypeScript side.
Using Signal Forms, the form is defined entirely in the TypeScript code, and the HTML template is only used to bind the form controls to the UI elements.
To create the form, use the form
function from the @angular/forms/signals
package that takes the form model as an argument (this needs to be a signal).
I like to create a separate method to initialize the form model, which can be reused to reset the form later on.
Within the HTML template, the model properties are bound to form controls using the controls
directive. This requires us to import the Control
directive from @angular/forms/signals
and add it to the imports
array of the component decorator.
This translates to the following HTML:
Validation link
Of course, a form isn't very useful without validation.
With Signal Forms, validation rules are defined within the second argument of the form
function.
This is a callback function that provides a path
parameter that is the root level of the given form model.
Via the path
variable you can access all the form controls and groups to define validation rules.
The rules themselves are similar to the ones used in Reactive Forms, but are standalone functions that are imported from the @angular/forms/signals
package.
In the example below, we only use the required
and maxLength
validators, but there are more built-in validators available, and you can also create custom validators. At the time of writing this, the built-in validators are: required
, minLength
, maxLength
, min
, max
, pattern
, and email
.
The validation is enabled by default, but it's possible to add a condition to only enable the validation when certain criteria are met. An example follows in the Nested Groups section.
The errors are exposed on each field via the errors
property, which is a signal that emits the current validation errors (if there are no errors the collection is empty). The error is expressed as a ValidationErrors
object that contains a kind
(identifies the kind of error), the field
(the field associated with this error), and an optional message
(human readable error message) property.
To display the errors in the UI, we can loop over the errors
collection and display each error message.
To keep it simple, this example just displays the JSON representation of the error object.
In a real-world application, I would recommend creating a dedicated component to display the errors in a more user-friendly way, or use a directive to dynamically for each control like I did in my Sandbox application.
To check the validatity of a control, use the valid
, invalid
, and pending
(useful for async validation) signals.
There is no status
property to get the current state as a string.
Differences link
At runtime, there are some differences compared to the existing form implementations:
- The validators are added to the input element as attributes, e.g.
required
andmaxlength
. - The name attribute is automatically generated based on the form control's path within the form model. This ensures that each form control has a unique name, which is important for form submission and accessibility.
- There are no classes added to the input element to indicate the validation state (
ng-valid
andng-invalid
,ng-touched
, etc.).
Control states link
Each form control has a set of signals to track its state, such as touched
(there is no untouched
counterpart), dirty
(there is no pristine
counterpart), disabled
, readonly
, and hidden
. Just as we're used to, the form control updates the touched
and dirty
states automatically based on user interaction. There are also the markAsTouched()
and markAsDirty()
methods to update the state programmatically.
Similar to how we define the validation rules, the conditions for the disabled
, readonly
, and hidden
states are be defined within the second argument of the form
function.
Differences link
Just as with validation, these states affect the input element at runtime:
- The
disabled
andreadonly
states are reflected as attributes on the input element. If these conditions are set to a parent group, they are applied to all child controls. Thehidden
state does not affect the input element, it's up to you to hide the element in the UI based on this state - Again, there are no classes added to the input element to indicate the state (
ng-touched
,ng-dirty
,ng-pristine
, etc.).
Nested groups link
The example form contains two nested groups for the billing and shipping address. Both have the same structure (city, street, and zip code), but the shipping address has an additional note field. Instead of repeating the same code twice, we can create some reusable components for the address fields.
The first part is that we can use the schema
function to define the structure and validation rules for an address. This function takes a callback that provides a path
parameter, similar to the one used in the form
function.
Next, we can apply this schema to both address groups in the main form definition.
Because the billing and shipping address are only applicable when the corresponding checkbox is checked, the address
schema
is only applied when the checkbox is checkbox. In code we use the applyWhen
function for this, if the schema can always be applied, use the apply
function instead.
In the example above you can also see the usage of the hidden
function to hide the address groups when the corresponding checkbox is not checked.
This makes it easy to manage the visibility of the groups based on the form state, instead of adding custom logic in the HTML template.
Lastly, we can create a reusable component for the address fields that takes an address as input.
While defining the input, the Property
type is used. To be honest, I don't know if this is the best approach, but I like the simplicity of it.
The HTML of the address sub form is identical to the controls of the main form.
The main form HTML now uses this component for both address groups, passing the corresponding address as input.
These address groups are only shown when the corresponding checkbox is checked, based on the hidden
state of the group.
:::
The note
property also shows that we can traverse into nested groups to bind controls to the UI elements.
Submitting the Form link
There are also some changes regarding form submission.
The form submission is handled by listening to the submit
event of the form element, and passes the click event to the handler method.
In the following example you can also notice that the submitting
signal is used to disable the submit button while the form is being submitted.
The submitting
signal is managed by Angular. To trigger the submit action, the submit
function from the @angular/forms/signals
package is used. This function takes the form signal and a callback that contains the logic to execute the form submission. Within the callback, the form is passed as an argument.
Important to note is that the callback is only invoked when the form is valid. If the form is invalid, the callback is not invoked, and the submitting
signal remains false
.
Before invoking the callback, the submit
method sets the submitting
signal to true
, and once the callback completes (either successfully or with an error), it resets the submitting
signal back to false
.
It also marks all controls, including the form itself, as touched and dirty.
Because the submit
function is a Promise
, we must await
it.
In the previous example, the form data is sent to a backend service using an HttpClient
call.
Because the submit
is asynchronous, and the HTTP call returns an Observable
, we must convert it to a Promise
. To do this, I used the firstValueFrom
function from rxjs
, which resolves the Promise
with the first emitted value from the Observable
.
If the HTTP call is successful, the form is reset to its initial state using the reset()
method, this resets all control states (touched, dirty, validations) of the form.
However, it does not reset the form value, so it's value is reset manually by calling the set
method of the value
signal.
If the HTTP call fails, the error is caught using the catchError
operator from rxjs
, and a server-side validation error is returned. This error is automatically added to the form's errors
collection, and can be displayed in the UI.
The form itself also has a collection of its own errors
. Which can be useful, but more useful is the ability to access all the errors including those its child controls. With the new signal forms, we can access these errors directly via the errorSummary
signal, which is also available on each control and group.
Just as with the errors of a control, the summary is a collection of ValidationError
objects, which can easily be traversed and displayed within the UI.
Differences link
If you have payed close attention to the submit
snippets you might have spotted the novalidate
attribute on the form element, as well as the event.preventDefault()
call in the submit handler.
This is done to prevent the browser's built-in validation from interfering with Angular's validation. Because the validators are added as attributes to the input elements, the browser automatically validate the form before it can submit, which is not desired in this case.
Additional notes link
Some extra thoughts while I was looking at the documentation and playing around with Signal Forms, but didn't fit in the previous sections.
- Instead of creating a custom validator, it's possible to use the
error
function to define a custom validation rule directly within the form definition. This can be useful in scenarios where the validation logic is simple and doesn't need to be reused elsewhere.
- Each control has a unique name that is automatically generated based on its path within the form model. This is a huge improvement for accessibility concerns, as it can serve to bind elements like labels and error messages to the corresponding form controls.
- It seems simpler to create cross-field validation rules and async validation rules, for more info see Manfred Steyer's blog post in the Extra Resources section.
Conclusion link
Signal Forms aims to simplify the API and improve performance by reducing the amount of boilerplate code needed to manage the form. By fiddling around using this small form that contains some often-used logic, I can see the potential of Signal Forms to become my go-to approach for building forms in Angular applications. While it's still in an experimental stage, it does already contains many features to build complex forms. While it can and will evolve over time, I would already consider it for applications that are not used in production environments.
One of the main reasons I like the new API is the conditional logic that can be applied to validation rules and control states. This makes it easy to manage complex forms with dynamic behavior. This was the main reason I was #TeamTemplateDrivenForms.
I also like that the form definition is now entirely in the TypeScript code, which makes it easier to reason about. The whole form is now defined in one place, instead of being split between the HTML template and the TypeScript code. I can also see that this can make it easier to test the form logic, as you're not required to render the HTML template to test the form (allthough I don't do this, and I also don't recommend it, see Angular Testing Library for the reasons behind it).
The benefit it has over Reactive Forms is that it's now built on top of Signals, which are more efficient than Observables for this use case as it needs to react on state changes (and not on events).
Because I test my forms from a user's perspective, this change to Signal Forms didn't affect my testing approach. The removal of the ng-
classes did not affect my tests. On the other hand, it makes it a harder to style the form based on its state. Maybe that this is a feature that will be added in the future.
There's one point that I don't like.
I'm not a big fan of the validation attributes being added to the input elements. For example, the maxlength
attribute is added to the input element, which will cut off the input when the user reaches the maximum length. This can be frustrating for users, especially when they paste a long text and don't notice that it has been cut off. I would prefer to have more control over this behavior, for example by providing an option to disable this feature. Maybe that this already exists, but I haven't come across it yet.
This makes me now in the camp of #TeamSignalForms.
The code used within this blog post can be accessed in repository (this will continue to change with the new versions), or you can also check out the commit in which I refactored the form to use Signal Forms.
Extra Resources link
- All About Angular’s New Signal Forms (blogpost), by Manfred Steyer
- Signal Forms Custom Controls — Simplifying ControlValueAccessor (YouTube), by Dmytro Mezhenskyi
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.