Updating value in parent component from child one causes ExpressionChangedAfterItHasBeenCheckedError in Angular
Asked Answered
C

4

19

I have two component: ParentComponent > ChildComponent and a service, e.g. TitleService.

ParentComponent looks like this:

export class ParentComponent implements OnInit, OnDestroy {

  title: string;


  private titleSubscription: Subscription;


  constructor (private titleService: TitleService) {
  }


  ngOnInit (): void {

    // Watching for title change.
    this.titleSubscription = this.titleService.onTitleChange()
      .subscribe(title => this.title = title)
    ;

  }

  ngOnDestroy (): void {
    if (this.titleSubscription) {
      this.titleSubscription.unsubscribe();
    }
  }    

}

ChildComponent looks like this:

export class ChildComponent implements OnInit {

  constructor (
    private route: ActivatedRoute,
    private titleService: TitleService
  ) {
  }


  ngOnInit (): void {

    // Updating title.
    this.titleService.setTitle(this.route.snapshot.data.title);

  }

}

The idea is very simple: ParentController displays the title on screen. In order to always render the actual title it subscribes to the TitleService and listens for events. When title is changed, the event happens and title is updated.

When ChildComponent loads, it gets data from the router (which is resolved dynamically) and tells TitleService to update the title with the new value.

The problem is this solution causes this error:

Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'undefined'. Current value: 'Dynamic Title'.

It looks like the value is updated in a change detection round.

Do I need to re-arrange the code to have a better implementation or do I have to initiate another change detection round somewhere?

  • I can move the setTitle() and onTitleChange() calls to the respected constructors, but I've read that it's considered a bad practice to do any "heavy-lifting" in the constructor logic, besides initializing local properties.

  • Also, the title should be determined by the child component, so this logic couldn't be extracted from it.


Update

I've implemented a minimal example to better demonstrate the issue. You can find it in the GitHub repository.

After thorough investigation the problem only occurred when using *ngIf="title" in ParentComponent:

<p>Parent Component</p>

<p>Title: "<span *ngIf="title">{{ title }}</span>"</p>

<hr>

<app-child></app-child>
Cuprite answered 20/6, 2017 at 14:23 Comment(6)
did you try to use ngAfterViewInit instead of ngOnInit inside the ParentComponent?Kaif
can you show your routes configuration?Colwell
and also component templates?Colwell
@Maximus Could you please explain, why do you need routes and templates? I'm not really sure it will help with resolving this issue somehow. The problem can be reproduced even without using the router and templates are very straightforward.Cuprite
@SlavaFominII, I just tried to reproduce it without router on v4.2 and everything works fine. Check this. Can you put up a plunker?Colwell
Thank you for taking your time to help @Maximus. After your suggestion I've made some important discovery. I've updated the question to explain it.Cuprite
C
20

The article Everything you need to know about the ExpressionChangedAfterItHasBeenCheckedError error explains this behavior in great details.

There are two possible solutions to your problem:

1) put app-child before ngIf:

<app-child></app-child>
<p>Title: "<span *ngIf="title">{{ title }}</span>"</p>

2) Use asynchronous event:

export class TitleService {
  private titleChangeEmitter = new EventEmitter<string>(true);
                                                       ^^^^^^--------------

After thorough investigation the problem only occurred when using *ngIf="title"

The problem you're describing is not specific to ngIf and can be easily reproduced by implementing a custom directive that depends on parent input that is synchronously updated during change detection after that input was passed down to a directive:

@Directive({
  selector: '[aDir]'
})
export class ADirective {
  @Input() aDir;

------------

<div [aDir]="title"></div>
<app-child></app-child> <----------- will throw ExpressionChangedAfterItHasBeenCheckedError

Why that happens actually requires a good understanding of Angular internals specific to change detection and component/directive representation. You can start with these articles:

Although it's not possible to explain everything in details in this answer, here is the high level explanation. During digest cycle Angular performs certain operations on child directives. One of such operations is updating inputs and calling ngOnInit lifecycle hook on child directives/components. What's important is that these operations are performed in strict order:

  1. Update inputs
  2. Call ngOnInit

Now you have the following hierarchy:

parent-component
    ng-if
    child-component

And Angular follows this hierarchy when performing above operations. So, assume currently Angular checks parent-component:

  1. Update title input binding on ng-if, set it to initial value undefined
  2. Call ngOnInit for ng-if.
  3. No update is required for child-component
  4. Call ngOnInti for child-component which changes title to Dynamic Title on parent-component

So, we end up with a situation where Angular passed down title=undefined when updating properties on ng-if but when change detection is finished we have title=Dynamic Title on parent-component. Now, Angular runs second digest to verify there's no changes. But when it compares to what was passed down to ng-if on the previous digest with the current value it detects a change and throws an error.

Changing the order of ng-if and a child-component in the parent-component template will lead to the situation when property on parent-component will be updated before angular updates properties for a ng-if.

Colwell answered 22/6, 2017 at 5:51 Comment(3)
Thank you for a thorough investigation. It really helped. I never though about actual order of components inside the template. It makes sense. However, changing the order of components in a template is not a good solution to this problem. Is there anything else I can do? Thanks!Cuprite
you can set title asynchronously, that will solve the problem, here is similar questionColwell
Making the EventEmitter in ServiceTitle asynchronous really helped to mitigate this issue. Thanks again. Could you add this solution to the answer itself? I will gladly accept it. Here's my title service code: gist.github.com/slavafomin/fd94cda8d63c1091e97e22ff6b7336cfCuprite
K
6

You could try using the ChangeDetectorRef so Angular will be manually notified about the change and the error won't be thrown.
After changing title in ParentComponent call the detectChanges method of the ChangeDetectorRef.

export class ParentComponent implements OnInit, OnDestroy {

  title: string;
  private titleSubscription: Subscription;

  constructor(private titleService: TitleService, private changeDetector: ChangeDetectorRef) {
  }

  public ngOnInit() {
    this.titleSubscription = this.titleService.onTitleChange()
      .subscribe(title => this.title = title);

    this.changeDetector.detectChanges();
  }

  public ngOnDestroy(): void {
    if (this.titleSubscription
    ) {
      this.titleSubscription.unsubscribe();
    }
  }

}
Kaif answered 20/6, 2017 at 15:6 Comment(0)
K
1

A 'quick' solution in some cases is to use setTimeout(). You need to consider all the caveats (see the articles other people have referred to) but for a simple case like just setting a title this is the easiest way.

ngOnInit (): void {

  // Watching for title change.
  this.titleSubscription = this.titleService.onTitleChange().subscribe(title => {

    setTimeout(() => { this.title = title; }, 0);

 });
}

Use this as a last resort type solution if you're not yet comfortable understanding all the complexities of change detection. Then come back and fix it another way when you are :)

Kile answered 20/7, 2018 at 19:54 Comment(0)
C
0

In my case, I was changing the state of my data--this answer requires you to read AngularInDepth.com explanation of digest cycle--inside the html level, all I had to do was change the way I was handling the data like so:

<div>{{event.subjects.pop()}}</div>

into

<div>{{event.subjects[0]}}</div>

Summary: instead of popping--which returns and remove the last element of an array thus changing the state of my data-- I used the normal way of getting my data without changing its state thus preventing the exception.

Chiropractor answered 5/2, 2018 at 19:28 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.