Refactoring a form to a Signal Form

profile
Tim Deschryver
timdeschryver.dev

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:

alt text

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:

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:

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.

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

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.

Buy Me a Coffee at ko-fi.com PayPal logo

Share this post