I'm seeking guidance on the best practice for using two-way binding with signals, particularly for complex objects in template-driven forms. Our team extensively uses template-driven forms and appreciates the simplicity of two-way binding for inputs. We're also excited about the new "model" input, which seems tailored for this purpose.
Currently, we use a viewModel signal to hold the state of our forms. These viewModels are typically objects, which appears to cause issues with two-way binding.
The problem:
Example code: you can just paste the code here: https://angular.dev/playground
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
signal,
WritableSignal
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { bootstrapApplication } from '@angular/platform-browser';
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule],
template: `
<!-- using two-way-binding with an object signal -->
<!-- this will never update the signal "properly" -->
<input type="text" [(ngModel)]="viewModel().name" />
<!-- is this the recommended way? -->
<!-- of course I could use primitive values for two-way-bindings only -->
<!-- but with larger forms this seems not so nice -->
<!-- especially when using multiple components with the model input -->
<input
type="text"
[ngModel]="viewModel().name"
(ngModelChange)="nameChange($event)"
/>
<br />
<!-- this will be updated because of the change detection that gets triggered by the input -->
<!-- the signal never notifies, because the reference is not changed -->
{{ viewModel().name }}
<br />
<button (click)="onClick()">click</button>
computed: {{ testComputed().name }}
`
})
export class CookieRecipe {
viewModel: WritableSignal<{ name: string }> = signal({ name: 'startName' });
testComputed = computed(() => {
// this will not be triggered, because the reference of the signal value is not changed.
console.warn('inside computed', this.viewModel());
return this.viewModel();
});
constructor() {
effect(() => {
// this will not be triggered, because the reference of the signal value is not changed.
console.warn('inside effect', this.viewModel());
});
}
onClick() {
console.warn('button clicked', this.viewModel());
// the set here will change the reference and therefore the signal will update the effect, the computed and the view
this.viewModel.set({ name: 'buttonClick' });
}
nameChange(name: string) {
this.viewModel.set({ ...this.viewModel, name });
}
ngDoCheck() {
console.warn('inside ngDoCheck');
}
}
bootstrapApplication(CookieRecipe);
Questions:
Any insights or recommendations would be greatly appreciated. Thank you!
One way is to take a bottom up approach.
model
s, this will handle the binding issue.name = model('startName');
password = model('startNamePassword');
<label for="name">Name:</label>
<input type="text" [(ngModel)]="name"
id="name"
name="name" />
<br/>
<label for="password">Password:</label>
<input
type="text"
id="password"
name="password"
[(ngModel)]="password"
/>
computed
that collects all the individual emits from the individual models and gives you the computed value. viewModel: Signal<{ name: string; password: string }> = computed(() => {
console.warn('inside computed');
return {
name: this.name(),
password: this.password(),
};
});
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
signal,
WritableSignal,
Signal,
model,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { bootstrapApplication } from '@angular/platform-browser';
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule],
template: `
<br/>
<label for="name">Name:</label>
<input type="text" [(ngModel)]="name"
id="name"
name="name" />
<br/>
<label for="password">Password:</label>
<input
type="text"
id="password"
name="password"
[(ngModel)]="password"
/>
<br />
{{ viewModel().name }}
<br />
<button (click)="onClick()">click</button>
computed: {{ viewModel().name }}
`,
})
export class CookieRecipe {
name = model('startName');
password = model('startNamePassword');
viewModel: Signal<{ name: string; password: string }> = computed(() => {
console.warn('inside computed');
return {
name: this.name(),
password: this.password(),
};
});
constructor() {
effect(() => {
// this will not be triggered, because the reference of the signal value is not changed.
console.warn('inside effect', this.viewModel());
});
}
onClick() {
console.warn('button clicked', this.viewModel());
}
ngDoCheck() {
console.warn('inside ngDoCheck');
}
}
bootstrapApplication(CookieRecipe);
All the below answers are my opinion based.
Is there a recommended approach to handle this scenario?
It's all upto your imagination, currently signals is in the phase of transitioning to forms support, so we have to wait.
Are there any plans to address this in future Angular releases?
As far as I know it's coming, we have to wait for the Team to release the standards for form signals
If not, what's the best current practice for managing complex form state with signals while maintaining the convenience of two-way binding?
Up to your imagination, you can use the building blocks of signals to achieve multiple ways of achieving the same thing, just until we get the new APIs for Angular signals.
Signals have been created to greatly improve the change detection of the views, and also to get rid of zonejs in the future.
They're not made for replacing everything that exists.
Moreover, the model
signal is just an input
that can get its value updated manually, then again it's not something created to work with ngModel exclusively.
The form modules (let it be template driven or reactive) are made to work well within your views and trigger the changes when needed : they do not need signals to perform well. Adding signals like you are trying to do is just adding complexity and antipatterns that should not be implemented.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With