Reputation: 10516
In Angular 2+, custom two-way databinding can be accomplished by using @Input
and @Output
parameters. So if I want a child component to communicate with a third party plugin, I could do it as follows:
export class TestComponent implements OnInit, OnChanges {
@Input() value: number;
@Output() valueChange = new EventEmitter<number>();
ngOnInit() {
// Create an event handler which updates the parent component with the new value
// from the third party plugin.
thirdPartyPlugin.onSomeEvent(newValue => {
this.valueChange.emit(newValue);
});
}
ngOnChanges() {
// Update the third party plugin with the new value from the parent component
thirdPartyPlugin.setValue(this.value);
}
}
And use it like this:
<test-component [(value)]="value"></test-component>
After the third party plugin fires an event to notify us of a change, the child component updates the parent component by calling this.valueChange.emit(newValue)
. The issue is that ngOnChanges
then fires in the child component because the parent component's value has changed, which causes thirdPartyPlugin.setValue(this.value)
to be called. But the plugin is already in the correct state, so this is a potentially unnecessary/expensive re-render.
So what I often do is create a flag property in my child component:
export class TestComponent implements OnInit, OnChanges {
ignoreModelChange = false;
ngOnInit() {
// Create an event handler which updates the parent component with the new value
// from the third party plugin.
thirdPartyPlugin.onSomeEvent(newValue => {
// Set ignoreModelChange to true if ngChanges will fire, so that we avoid an
// unnecessary (and potentially expensive) re-render.
if (this.value === newValue) {
return;
}
ignoreModelChange = true;
this.valueChange.emit(newValue);
});
}
ngOnChanges() {
if (ignoreModelChange) {
ignoreModelChange = false;
return;
}
thirdPartyPlugin.setValue(this.value);
}
}
But this feels like a hack.
In Angular 1, directives which took in a parameter using the =
binding had the same exact issue. So instead, I would accomplish custom two-way databinding by requiring ngModelController
, which did not cause a re-render after a model update:
// Update the parent model with the new value from the third party plugin. After the model
// is updated, $render will not fire, so we don't have to worry about a re-render.
thirdPartyPlugin.onSomeEvent(function (newValue) {
scope.$apply(function () {
ngModelCtrl.$setViewValue(newValue);
});
});
// Update the third party plugin with the new value from the parent model. This will only
// fire if the parent scope changed the model (not when we call $setViewValue).
ngModelCtrl.$render = function () {
thirdPartyPlugin.setValue(ngModelCtrl.$viewValue);
};
This worked, but ngModelController
really seems to be designed for form elements (it has built in validation, etc.). So it felt a bit odd to use it in custom directives which are not form elements.
Question: Is there a best practice in Angular 2+ for implementing custom two-way databinding in a child component, which does not trigger ngOnChanges
in the child component after updating the parent component using EventEmitter
? Or should I integrate with ngModel
just as I did in Angular 1, even if my child component is not a form element?
Thanks in advance!
Update: I checked out Everything you need to know about change detection in Angular suggested by @Maximus in the comments. It looks like the detach
method on ChangeDetectorRef
will prevent any bindings in the template from being updated, which could help with performance if that's your situation. But it does not prevent ngOnChanges
from being called:
thirdPartyPlugin.onSomeEvent(newValue => {
// ngOnChanges will still fire after calling emit
this.changeDetectorRef.detach();
this.valueChange.emit(newValue);
});
So far I haven't found a way to accomplish this using Angular's change detection (but I learned a lot in the process!).
I ended up trying this with ngModel
and ControlValueAccessor
. This seems to accomplish what I need since it behaves as ngModelController
in Angular 1:
export class TestComponentUsingNgModel implements ControlValueAccessor, OnInit {
value: number;
// Angular will pass us this function to invoke when we change the model
onChange = (fn: any) => { };
ngOnInit() {
thirdPartyPlugin.onSomeEvent(newValue => {
this.value = newValue;
// Tell Angular to update the parent component with the new value from the third
// party plugin
this.onChange(newValue);
});
}
// Update the third party plugin with the new value from the parent component. This
// will only fire if the parent component changed the model (not when we call
// this.onChange).
writeValue(newValue: number) {
this.value = newValue;
thirdPartyPlugin.setValue(this.value);
}
registerOnChange(fn: any) {
this.onChange = fn;
}
}
And use it like this:
<test-component-using-ng-model [(ngModel)]="value"></test-component-using-ng-model>
But again, if the custom component is not a form element, using ngModel
seems a bit odd.
Upvotes: 6
Views: 10340
Reputation: 3064
Also ran into this problem (or at least something very similar).
I ended up using hacky approach you discussed above but with a minor modification, I used setTimeout in order to reset state just in case.
(For me personally ngOnChanges was mainly problematic if using two-way binding, so the setTimeout prevents a hanging disableOnChanges if NOT using two-way binding).
changePage(newPage: number) {
this.page = newPage;
updateOtherUiVars();
this.disableOnChanges = true;
this.pageChange.emit(this.page);
setTimeout(() => this.disableOnChanges = false, 0);
}
ngOnChanges(changes: any) {
if (this.disableOnChanges) {
this.disableOnChanges = false;
return;
}
updateOtherUiVars();
}
Upvotes: 7
Reputation: 344
This is exactly the intention of Angular and its something you should try to work with rather than against. Change detection works by components detecting changes in its template bindings and propagating them down the component tree. If you can design your application in such a way that you are relying on the immutability of components inputs', you can control this manually by setting @Component({changeDetection:ChangeDetectionStrategy.OnPush})
which will test references to determine whether to continue change detection on children components.
So, that said, my experience is that wrappers of 3rd party plugins may not efficiently handle and take advantage of this type of strategy appropriately. You should attempt to use knowledge of the above, together with good design choices like the separation of concerns of presentation vs container components to leverage the detection strategy to achieve good performance.
You can also pass changes: SimpleChanges
to ngOnInit(changes: SimpleChanges)
and inspect the object to learn more about your data flow.
Upvotes: 0