Angular2 - Expression has changed after it was checked - Binding to div width with resize events
Asked Answered
F

5

43

I have done some reading and investigation on this error, but not sure what the correct answer is for my situation. I understand that in dev mode, change detection runs twice, but I am reluctant to use enableProdMode() to mask the issue.

Here is a simple example where the number of cells in the table should increase as the width of the div expands. (Note that the width of the div is not a function of just the screen width, so @Media cannot easily be applied)

My HTML looks as follows (widget.template.html):

<div #widgetParentDiv class="Content">
<p>Sample widget</p>
<table><tr>
   <td>Value1</td>
   <td *ngIf="widgetParentDiv.clientWidth>350">Value2</td>
   <td *ngIf="widgetParentDiv.clientWidth>700">Value3</td>
</tr></table>

This on its own does nothing. I'm guessing this is because nothing is causing change detection to occur. However, when I change the first line to the following, and create an empty function to receive the call, it starts working, but occasionally I get the 'Expression has changed after it was checked error'

<div #widgetParentDiv class="Content">
   gets replaced with
      <div #widgetParentDiv (window:resize)=parentResize(10) class="Content">

My best guess is that with this modification, change detection is triggered and everything starts responding, however, when the width changes rapidly the exception is thrown because the previous iteration of change detection took longer to complete than changing the width of the div.

  1. Is there a better approach to triggering the change detection?
  2. Should I be capturing the resize event through a function to ensure change detection occurs?
  3. Is using #widthParentDiv to access the width of the div acceptable?
  4. Is there a better overall solution?

For more details on my project please see this similar question.

Thanks

Featheredge answered 13/8, 2016 at 7:6 Comment(4)
I'm not sure I understand what you are looking for... and yet... First, why do you write *ngif=".... This may be a correct syntax (didn't bother to check it though), but I know for sure that ng-if="..." works when the tested condition is within the scope of Angular. As such, you may wish to try changing this and having a $scope variable being updated to enable the test as you wish (i.e. width vs. thresholds).Vining
Hi David, thanks for your comment. I am only new to Angular/Angular2, but I think ng-if & $scope are used only in Angular and have been replaced in Angular2. The link in the bottom line further explains my objective if you are interested. CheersFeatheredge
Ooops!!! Missed the "2". Sorry. I'll stick with Angular for the time being :-).Vining
It's a difference between "Angular" which means 2+ and "AngularJS".Wroughtup
S
69

To solve your issue, you simply need to get and store the size of the div in a component property after each resize event, and use that property in the template. This way, the value will stay constant when the 2nd round of change detection runs in dev mode.

I also recommend using @HostListener rather than adding (window:resize) to your template. We'll use @ViewChild to get a reference to the div. And we'll use lifecycle hook ngAfterViewInit() to set the initial value.

import {Component, ViewChild, HostListener} from '@angular/core';

@Component({
  selector: 'my-app',
  template: `<div #widgetParentDiv class="Content">
    <p>Sample widget</p>
    <table><tr>
       <td>Value1</td>
       <td *ngIf="divWidth > 350">Value2</td>
       <td *ngIf="divWidth > 700">Value3</td>
      </tr>
    </table>`,
})
export class AppComponent {
  divWidth = 0;
  @ViewChild('widgetParentDiv') parentDiv:ElementRef;
  @HostListener('window:resize') onResize() {
    // guard against resize before view is rendered
    if(this.parentDiv) {
       this.divWidth = this.parentDiv.nativeElement.clientWidth;
    }
  }
  ngAfterViewInit() {
    this.divWidth = this.parentDiv.nativeElement.clientWidth;
  }
}

Too bad that doesn't work. We get

Expression has changed after it was checked. Previous value: 'false'. Current value: 'true'.

The error is complaining about our NgIf expressions -- the first time it runs, divWidth is 0, then ngAfterViewInit() runs and changes the value to something other than 0, then the 2nd round of change detection runs (in dev mode). Thankfully, there is an easy/known solution, and this is a one-time only issue, not a continuing issue like in the OP:

  ngAfterViewInit() {
    // wait a tick to avoid one-time devMode
    // unidirectional-data-flow-violation error
    setTimeout(_ => this.divWidth = this.parentDiv.nativeElement.clientWidth);
  }

Note that this technique, of waiting one tick is documented here: https://angular.io/docs/ts/latest/cookbook/component-communication.html#!#parent-to-view-child

Often, in ngAfterViewInit() and ngAfterViewChecked() we'll need to employ the setTimeout() trick because these methods are called after the component's view is composed.

Here's a working plunker.


We can make this better. I think we should throttle the resize events such that Angular change detection only runs, say, every 100-250ms, rather then every time a resize event occurs. This should prevent the app from getting sluggish when the user is resizing the window, because right now, every resize event causes change detection to run (twice in dev mode). You can verify this by adding the following method to the previous plunker:

ngDoCheck() {
   console.log('change detection');
}

Observables can easily throttle events, so instead of using @HostListener to bind to the resize event, we'll create an observable:

Observable.fromEvent(window, 'resize')
   .throttleTime(200)
   .subscribe(_ => this.divWidth = this.parentDiv.nativeElement.clientWidth );

This works, but... while experimenting with that, I discovered something very interesting... even though we throttle the resize event, Angular change detection still runs every time there is a resize event. I.e., the throttling does not affect how often change detection runs. (Tobias Bosch confirmed this: https://github.com/angular/angular/issues/1773#issuecomment-102078250.)

I only want change detection to run if the event passes the throttle time. And I only need change detection to run on this component. The solution is to create the observable outside the Angular zone, then manually call change detection inside the subscription callback:

constructor(private ngzone: NgZone, private cdref: ChangeDetectorRef) {}
ngAfterViewInit() {
  // set initial value, but wait a tick to avoid one-time devMode
  // unidirectional-data-flow-violation error
  setTimeout(_ => this.divWidth = this.parentDiv.nativeElement.clientWidth);
  this.ngzone.runOutsideAngular( () =>
     Observable.fromEvent(window, 'resize')
       .throttleTime(200)
       .subscribe(_ => {
          this.divWidth = this.parentDiv.nativeElement.clientWidth;
          this.cdref.detectChanges();
       })
  );
}

Here's a working plunker.

In the plunker I added a counter that I increment every change detection cycle using lifecycle hook ngDoCheck(). You can see that this method is not being called – the counter value does not change on resize events.

detectChanges() will run change detection on this component and its children. If you would rather run change detection from the root component (i.e., run a full change detection check) then use ApplicationRef.tick() instead (this is commented out in the plunker). Note that tick() will cause ngDoCheck() to be called.


This is a great question. I spent a lot of time trying out different solutions and I learned a lot. Thank you for posting this question.

Slue answered 13/8, 2016 at 23:21 Comment(8)
Mark, thankyou so much for your detailed answer. I have been stuck on this for weeks now and am looking forward to getting back into my project :)Featheredge
Based on this, I'm after some advice on how to go about building my full app. The link at the bottom of my op explains the objective, but the short version is that I want to have multiple widgets that each show content which varies based on their size like my example. My intention is that each widget would be responsible for managing itself, but now think the width hook should occur once at the top level and pass to each widget. In that case, the top level app could just tell the widget its size, but I really wanted to seperate responsibilites. Any thoughts based on my other post? Ill plunk itFeatheredge
Mark - FYI -, I had to import ElementRef when I merged your plunker into my code. Any ideas why you did not need to in your plunk? Secondly, I am getting an error ~Argument of type '(_: any) => Subscription' is not assignable to parameter of type '() => any'.` at this.ngzone.runOutsideAngular(_ => Observable.fromEvent(window, 'resize'). Any ideas how to fix this please?Featheredge
@AnthonyDay, ElementRef should be imported. (I guess Plunker is lax here.) Use this.ngzone.runOutsideAngular( () => ...) instead of this.ngzone.runOutsideAngular(_ => ...).Slue
Thanks Mark. That fixed it.Featheredge
You have impressed me @Mark Rajcok, I would like to adapt your last sentence like this : "This is a great answer. I spent one minute trying out different solutions and I found your post. Thank you VERY MUCH for posting this awesome answer!"Searles
@MarkRajcok Since we are subscribing to a observable outside Angular. We should explicitly unsubscribe when the component is destroyed to prevent memory leak. https://mcmap.net/q/390553/-how-to-unsubscribe-several-subscriber-in-angular-2Mccarter
This worked thanks. One issue I ran into though with this. Observable.fromEvent(window, 'resize').throttleTime(200) I was getting the error throttleTime is not a function. Using Observable.fromEvent(window, 'resize').debounceTime(200) worked however.Transpire
L
17

Other way that i used to resolve this:

import { Component, ChangeDetectorRef } from '@angular/core';


@Component({
  selector: 'your-seelctor',
  template: 'your-template',
})

export class YourComponent{

  constructor(public cdRef:ChangeDetectorRef) { }

  ngAfterViewInit() {
    this.cdRef.detectChanges();
  }
}
Lysias answered 21/9, 2017 at 20:31 Comment(1)
In my case it worked by using below code, ngAfterViewChecked() { this.cdRef.detectChanges(); }Fauces
S
3

Simply use

setTimeout(() => {
  //Your expression to change if state
});
Salable answered 1/2, 2019 at 9:8 Comment(0)
B
0

The best solution is to use setTimeout or delay on the services.

https://blog.angular-university.io/angular-debugging/

Bouffe answered 31/7, 2019 at 20:35 Comment(0)
C
0

Mark Rajcok gave a great answer. The simpler version (without throttling) would be:

ngAfterViewInit(): void {
    this.windowResizeSubscription = fromEvent(window, 'resize').subscribe(() => this.onResize())
    this.onResize() // to initialize before any change
  }

onResize() {
    this.width = this.elementRef.nativeElement.getBoundingClientRect().width;
    this.changeDetector.detectChanges();
  }
Coquille answered 28/10, 2021 at 20:42 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.