Recently, I encountered this challenge... Refactor a form component to Angular Signals.
The old form component works like this:
- The form data comes from a reactive state service
- The form data is an object
- The form data is cloned before it is passed to the form component
- The form component receives the form data via one classic decorator-based Angular @Input
@Input({required: true})
user!: User;
- The form is a template-driven form and uses [(ngModel)] to mutate the form data object
<div>
<label for="firstName">First Name</label>
<input id="firstName" name="firstName" [(ngModel)]="user.firstName" />
</div>
- Clicking the save button would send the mutated object to the parent component via an Angular @Output
- The parent component updates the reactive state service
This setup works great in many of our applications.
You can review this setup in this StackBlitz which showcases the basic principle: Form with classic Angular @Input
Refactor to Signal Input
With Angular Signal Input we can make our component inputs reactive. This sounds great!
FYI Signal Inputs are the recommended way for new projects. From the Angular docs:
Let's refactor our classic Angular @Input to a Signal Input:
user = input.required<User>();
Signal Input object value and [(ngModel)]
The Signal Input receives an object of type User. We try to bind to the object properties with [(ngModel)]
.
This code looks so nice 😍!
<div>
<label for="firstName">First Name</label>
<input
id="firstName"
name="firstName"
[(ngModel)]="user().firstName"
/>
</div>
And in this StackBlitz everything seems to work: Form with Signal Input - mutating Signal State
‼️ Danger Zone:
But wait..., we have just entered the danger zone... ☢️ ☣️ ⚠️
What is actually happening?
-
user()
unwraps the Input Signal. We get hold of the raw user object - This line of code
[(ngModel)]="user().firstName"
will mutate the user object (which is wrapped into a Signal) whenever the text input value changes
☢️ Mutating the Signal object ☣️
Why is mutating the Signal state a bad idea? Because we are bypassing the Signals public API to update state. Normally we should only use the dedicated methods set
or update
in order to update the Signal state.
Other developers might want to build other Signals on top of the user
Signal with Angular computed
. But computed will never be triggered because the user
Signal does not know about the user object mutations. This might be a surprising behavior.
Signal Inputs are read-only
And yes, there is another reason, why using [(ngModel)]
on a Signal Input is at least strange. Signal Inputs are supposed to be read-only. They do not have a set
or update
method - so there is no official support for changing Signal Input state programmatically.
Rescue
Let's try to escape as fast as possible 🚀. What are possible solutions?
Linked Signal
With Linked Signal we can create a writable user Signal, which is updated automatically, whenever the user Signal Input receives a new value. At the same time we can update the Linked Signal with set
and update
.
editableUser
is our Linked Signal...
export class UserDetailComponent {
user = input.required<User>();
editableUser = linkedSignal(() => this.user());
updateEditableUser(v: Partial<User>) {
this.editableUser.update(state => ({...state, ...v}))
}
}
We also introduced a updateEditableUser
method which uses the public Signal API to update the Signal state: in this case we call the update
method of the Linked Signal.
<div>
<label for="firstName">First Name</label>
<input
id="firstName"
name="firstName"
[ngModel]="editableUser().firstName"
(ngModelChange)="updateEditableUser({firstName: $event})"
/>
</div>
[(ngModel)]
has been split into [ngModel]
and (ngModelChange)
-
[ngModel]="editableUser().firstName"
will update the text input when the firstName property of the user object has been updated - whenever the text input value changes, the ngModelChange callback (
updateEditableUser
) will be executed and the Linked Signal will be updated
PROs
- we use the public Signal API to update state
- we use Linked Signal which is a writable signal
- state changes happen explicitly in a dedicated method
- object clone is not needed
CONs
- There is some boilerplate necessary: Linked Signal setup, ngModel, ngModelChange, method for state update
- At the moment, Linked Signal is still in developer preview (Angular 19)
StackBlitz: Form with Signal Input - Linked Signal
Effect
An alternative approach is using Angular effect
to listen to new values of the Signal input.
When we receive a new value from the Input we assign the raw value to a local class property.
export class UserDetailComponent {
_user = input.required<User>({alias: 'user'});
user!: User;
constructor() {
effect(() => this.user = this._user())
}
}
<div>
<label for="firstName">First Name</label>
<input
id="firstName"
name="firstName"
[(ngModel)]="user.firstName"
/>
</div>
In the template we can mutate the raw object as we always did.
PROs
- The template is identical to our original old-school form component which used a classic Angular @Input
- We officially mutate a raw object (we do not bypass Signal APIs, we do not mutate the read-only Signal Input state).
CONs
-
user: User
must be initialised:user: User = new User();
or we must tell TypeScript that user is always defined withuser!: User;
- cloning is needed (happens in the parent with the structuredClone pipe)
- naming challenge:
_user
Signal,'user'
alias,user
property for the raw user object
StackBlitz: Form with Signal Input - Effect
@let approach
With @let, we can declare variables in the template.
Let's use @let to get the raw object from the user Signal Input. Additionally, we can also perform the clone with our structuredClone pipe.
@let userClone = user() | structuredClone;
<div>
<label for="firstName">First Name</label>
<input
id="firstName"
name="firstName"
[(ngModel)]="userClone.firstName"
/>
</div>
TypeScript:
export class UserDetailComponent {
user = input.required<User>();
}
PROs
- Minimum boilerplate, and smallest code change in comparison the old-school (@Input) form component
- @let is the single place to let the magic happen (unwrap the Signal, clone)
CONs
- Object value is required, it does not work with primitive values. See Notes below for more details
- cloning is needed
StackBlitz: Form with Signal Input - @let
Notes
Object vs Primitive Values
In the examples above, the component Input received an object value (the user object).
The Linked Signal and Effect approaches would also work with Signal Inputs which use primitive values (string, boolean, number...).
Let's have a look at the @let approach:
@let variables are read-only... which means we can not reassign new values to a @let variable. Also, not via [(ngModel)]. If we try, then we can see this compile error:
The @let approach requires an object value, which we can mutate.
Conclusion
The "@let approach" seems to be the most straight-forward solution with a minimum of boilerplate.
Effect and Linked Signal are also valid options, but require more setup.
We are still evaluating which approach is most suited for our applications and this blogpost should help us to make a good decision.
I hope that you enjoyed our short visit to the danger zone of mutating Signal state. What do you think of my escape strategies? I am sure there are even more options. Let me know in the comments.
Thank you!
Top comments (14)
Just a question, but how is this better than the FormBuilder? I don’t see the comparison in this article.
Reactive Forms are indeed not covered in the blog post, the focus is mutating the form data with ngModel which is only available in Template Driven Forms.
But still, I was curious what the setup would look like with Reactive Forms: stackblitz.com/edit/stackblitz-sta...
I believe that you have to use
effect
to update the form value withFormGroup.setValue
.Yeah that’s cool. I just think opening with the fact this is about Template Driven forms would have avoided the confusion. :)
Probably because it's a separate topic and it's a matter of opinion. Template-driven forms are much simpler and straightforward. Reactive forms are more robust and manageable. You do you, basically.
We try to avoid Reactive Forms as much as possible.
Too much boilerplate for almost no gain.
Template Forms are Reactive Forms under the hood. So you get almost the same functionality.
Prefer Template-Driven Forms | Ward Bell | ng-conf 2021
I will watch the video later today, however while I’m not a super user of Angular I find the order that ReactiveForms FormGroups brings is where I appreciate it more. Having to manage individual fields feels messy to me when working on larger input systems - especially delving into Angular Material and Steppers but I’m more a PoC person :)
Yup see my response about opening with it being about template driven forms above.
Signals are not an improvement here. They make thinks more complicated and require more code.
All things considered I think linkedSignal is a better option. I don't like opting out of signals and like @oz mentioned not everything can be cloned.
Some of the boilerplate can be reduced with a directive and a model input:
stackblitz.com/edit/stackblitz-sta...
Is there any way to work the new "model signal" into the mix? I guess maybe not since you're looking to work with properties of an object?
You could replace the user input and the save output with Input
model
.Input model is meant to sync data between child and parent component and vice versa: angular.dev/guide/components/input...
The challenges regarding the usage of [(ngModel)] will be the same also with Input model. I assume that you can apply the same solutions from the blog post.
Great explanation
Some things just can not be cloned (because of circular references, functions inside, or links to DOM nodes). FormGroup, FormControl, for example.
Option with linkedSignal is the way to go.
The cloning is restricted to the form data (the user Object) which is manipulated with ngModel.