Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Prevent ngOnChanges from firing after emitting event (Angular 2+)

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.

like image 239
Frank Modica Avatar asked May 07 '17 18:05

Frank Modica


1 Answers

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();
}
like image 50
craigrs84 Avatar answered Sep 30 '22 06:09

craigrs84