Angular OnPush: how do I force changes to be detected from outside?
Asked Answered
B

1

14

I have a scenario where I have two components sitting alongside each other, ComponentA and ComponentB. They both use a service Service.

ComponentB contains a button which will make a property in Service, Service.counter, go up by 1.

ComponentA renders the value of Service.counter.

However, when I use ChangeDetectionStrategy.OnPush, no matter what I try, I cannot get the value in ComponentA to update, even from the root component, where I tried this:

this.cdr.markForCheck();
this.cdr.detectChanges();
this.ar.tick();
this.zone.run(() => {});

Without having to make changes to ComponentA, how can I make sure it always shows the right value?

(The real world scenario is that there's a lot of components like ComponentA all rendering translated values, and when the selected language changes, I need all these translated values to update accordingly. I don't want to build a listener into each individual component and call detectChanges from there)

Berg answered 29/4, 2019 at 13:55 Comment(1)
If you don't want to build a listener into each component, could you write a parent class that listens for changes from the service and have all your ComponentBs extend it? If not, you could use inputs to those components, which will trigger an onPush change.Cherokee
C
14

However, when I use ChangeDetectionStrategy.OnPush, no matter what I try, I cannot get the value in ComponentA to update, even from the root component, where I tried this:

A component has an associated view. The view references the DOM and is what we want to be updated. When you use OnPush the view of a component needs to be marked as dirty if the component's state changes externally.

When you say even from the root component it means you're trying to mark the wrong view as dirty. If you want to see changes in ComponentA then you need to flag that component view as dirty.

Something like this.

@Component({...})
public class ComponentA implements OnInit {
    public count; // rendered in the view

    public constructor(private _change: ChangeDetectorRef,
                       private _service: MyService) {
    }

    public onInit() {
         this._service.getCounter().subscribe(value=> {
             this.count = value; // will not update the view.
             this._change.markForCheck(); // tell Angular it's dirty
         });
    }
}

So the above will work in 99% of the cases, but if the getCounter() methods returns an observable that executes outside the scope of Angular, and you have to do this explicitly because async operations are automatically zoned, then you have to use the zone.run() method. Otherwise, even if you mark the view dirty. Angular isn't going to check if any views need to be updated. This should not happen unless you're using non-Angular events or have explicitly run outside of Angular.

The alternative is to use the async pipe, and is the easier approach.

@Component({
    template: `<span>{{count$ | async}}</span>`
})
public class ComponentA implement OnInit {
    public count$: Observable<number>;

    public constructor(private _service: MyService) {
    }

    public onInit() {
        this.count$ = this._service.getCounter();
    }
}

The async pipe uses a reference to ChangeDetectorRef will also mark the view as dirty for you. So it saves you from a lot of boilerplate code.

The real world scenario is that there's a lot of components like ComponentA all rendering translated values, and when the selected language changes, I need all these translated values to update accordingly. I don't want to build a listener into each individual component and call detectChanges from there

Then you best bet is to use the async pipe and make your components reactive.

If we're talking about something large scale and effects a lot of components, then maybe this root component should pass the value down to components as an @Input() which will also trigger them to be rendered. While this creates a coupling between all of the components it says you from having to worry about updating the views.

Campbellite answered 29/4, 2019 at 14:13 Comment(3)
I considered using a pipe for this (in fact, i'm using @ngx-translate which has |translate which appears to be doing what you suggest), but for some reason, even this I cannot get to work. See this little project here wetransfer.com/downloads/…. Cmp1 tries to render the value from the service using a pipe which calls markForCheck whenever the value changes, but it doesn't change in the view. What am I doing wrong here?Berg
I just realised my example doesn't work because I didn't set pure to false on the pipe, so after changing that it actually works! Now to figure out why it doesn't work in my project...Berg
For me your wetransfer link does not work. It's always the best idea to show the relevant code parts right here or to check in your code into GitHub for example.Hesperidin

© 2022 - 2024 — McMap. All rights reserved.