DEV Community

Cover image for Two-way bind a Signal Input object value with [(ngModel)]
Florian Spier for This is Angular

Posted on • Edited on

Two-way bind a Signal Input object value with [(ngModel)]

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;
Enter fullscreen mode Exit fullscreen mode
<div>
  <label for="firstName">First Name</label>
  <input id="firstName" name="firstName" [(ngModel)]="user.firstName" />
</div>
Enter fullscreen mode Exit fullscreen mode
  • 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:

Image description

Let's refactor our classic Angular @Input to a Signal Input:

user = input.required<User>();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}))
  } 
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

[(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())
  }
}
Enter fullscreen mode Exit fullscreen mode
<div>
  <label for="firstName">First Name</label>
  <input 
    id="firstName" 
    name="firstName" 
    [(ngModel)]="user.firstName" 
  />
</div>
Enter fullscreen mode Exit fullscreen mode

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 with user!: 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>
Enter fullscreen mode Exit fullscreen mode

TypeScript:

export class UserDetailComponent {
  user = input.required<User>();
}
Enter fullscreen mode Exit fullscreen mode

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:

Image description

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)

Collapse
 
anthonyikeda profile image
Anthony Ikeda

Just a question, but how is this better than the FormBuilder? I don’t see the comparison in this article.

Collapse
 
spierala profile image
Florian Spier

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 with FormGroup.setValue.

Collapse
 
anthonyikeda profile image
Anthony Ikeda

Yeah that’s cool. I just think opening with the fact this is about Template Driven forms would have avoided the confusion. :)

Collapse
 
fyodorio profile image
Fyodor

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.

Collapse
 
spierala profile image
Florian Spier

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

Thread Thread
 
anthonyikeda profile image
Anthony Ikeda

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 :)

Collapse
 
anthonyikeda profile image
Anthony Ikeda

Yup see my response about opening with it being about template driven forms above.

Collapse
 
avareto profile image
Andreas Kuhlmann

Signals are not an improvement here. They make thinks more complicated and require more code.

Collapse
 
omergronich profile image
Omer Gronich • Edited

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...

Collapse
 
jwess profile image
Joel

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?

Collapse
 
spierala profile image
Florian Spier • Edited

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.

Collapse
 
santoshyadavdev profile image
Santosh Yadav

Great explanation

Collapse
 
oz profile image
Evgeniy OZ

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.

Collapse
 
spierala profile image
Florian Spier

The cloning is restricted to the form data (the user Object) which is manipulated with ngModel.