Change Detection: When using setTimeout() is not your best option

Stephen Cooper - Nov 4 '19 - - Dev Community

If you have been working with Angular then the chances are pretty high that you have run into the ExpressionChangedAfterItHasBeenCheckedError. Well I did again last week and what follows is an outline of what I learnt while iterating through my 'solutions' to reach the correct fix.



ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. 


Enter fullscreen mode Exit fullscreen mode

Parent and Child Component with shared Service

There are a few different scenarios that cause this error but I am only going to focus on the one I faced. I ran into the error with the following setup. We have a parent component that contains a configurable filter component. Both the parent and filter component use a shared filter service.

Our parent component subscribes to the filterValues$ observable from the filter service to receive the latest filter changes.



@Component({...})
export class ParentComponent {

  constructor(private filterService: FilterPanelService) {
    // Subscribe to changes of the filter values
    this.filterValues$ = this.filterService.filterValues$;
  }
}


Enter fullscreen mode Exit fullscreen mode

The filter component dynamically creates its inputs based on the config provided by the parent. It also is responsible for initialising the filter service for the given config.



@Component({...})
export class FilterComponent implements OnInit {
  @Input()
  configs: FilterConfig[];

  ngOnInit() {
    // Initialise the filter service based on filter configs
    this.filterService.init(this.configs);
    // Setup filter inputs for configs
  }
}


Enter fullscreen mode Exit fullscreen mode

The filter service provides the initial values, for the given config, and exposes an observable of the current filter state.



@Injectable()
export class FilterService<T> {
  private filterValueSubject = new Subject<T>();
  public filterValues$: Observable<T>;

  constructor(){
    // Expose changes as an observable
    this.filterValues$ = this.filterValueSubject.asObservable();
  }

  init(configs: FilterConfig[]) {
    // Get initial values and push these out
    const initValues = this.getInitialValues(configs);    
    this.filterValueSubject.next(val);
  }
}



Enter fullscreen mode Exit fullscreen mode

Let me try and explain why this setup runs into issues with Angular's change detection. After the parent component is setup Angular takes a snapshot of its current state. It then moves down the component tree to setup the filter component.

During the setup of the filter component we initialise the filter service which causes an update to be sent back to the parent component via its subscription to filterValues$. This means that in dev mode, when the second change detection cycle is run, the snapshotted state of the parent component does not match its current value. This difference is reported to us with the expression changed after it was checked error.



ERROR Error: ExpressionChangedAfterItHasBeenCheckedError


Enter fullscreen mode Exit fullscreen mode

Why is this a problem?

Why is Angular telling us that this is a problem? To make Angular's change detection more efficient it expects unidirectional data flow. What we have created is a feedback loop, or bidirectional data flow. It means that when a child component updates the bindings of a parent component that change is not going to be reflected in the DOM until another change detection cycle is run. This will manifest to the user as stale/invalid state.

Feedback loop via service observable

I would highly recommend taking the time to read the following two articles by Maxim Koretskyi. The first fully explains unidirectional data flow and the second goes into great detail about the expression changed error. These two articles helped me understand this topic so much better!

Incorrect: Just use OnPush strategy...

One way of stopping the exception is to update the ChangeDetectionStrategy to OnPush.



@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
}


Enter fullscreen mode Exit fullscreen mode

This stops the exception being thrown but does not fix the bug that we have in our code that Angular is trying to warn us about!

Below our filter component we have a button which is conditionally disabled based on the filter state. At the time our parent component is setup the filter values have not been initialised and so the button is disabled. During the setup of the filter component the values are initialised meaning the button should be enabled, but it is still disabled. Triggering any event in our view, i.e focus/blur, suddenly causes the button to become enabled. This happens as change detection has been triggered by the DOM events and so our component then correctly reflects its state.

So don't just change to OnPush to silence the development environment exception. You very likely will still have a bug when running in production!

Okayish: Use a setTimeout...

Another very popular solution is to make the problematic code async. This is done by using a setTimeout or adding delay(0) to our pipe in RxJs. In our case we could add a delay(0) to the filterValues$ observable to make these updates happen asynchronously.



    // BEFORE: Synchronous updates with error
    //this.filterValues$ = this.filterValueSubject.asObservable();

    // AFTER: Asynchronous updates working
    this.filterValues$ = this.filterValueSubject.asObservable()
                                                .pipe(delay(0));


Enter fullscreen mode Exit fullscreen mode

This works because change detection runs synchronously so the values in the component are still the same after the filter service has been setup. Once this is completed our asynchronous update kicks off, triggering a second change detection cycle which enables our component to reflect the correct data state.

While this approach resolves the exception and fixes our display bug we have cluttered our code and degraded the performance of our app. So while this is a popular solution it clearly is not optimal.

Can we do any better?

Best: Respect Unidirectional Data Flow

Instead of working around Angular's requirement for unidirectional data flow lets update our data flow to respect it. For me this took a bit more thought to get right but the result is definitely worth it.

As a reminder our issue is caused by the feedback loop of the filter service being initialised by the filter component which then causes an update in the parent component.

The light bulb moment came when I realised that we could get the parent component to initialise the filter service as part of its setup. This means that the parent has the final filter values before the child filter component is setup. This way when our filter component is setup the filter service is already primed and the filter component can just gets its initial values. We now have unidirectional data flow and unsurprisingly our errors all disappear!




@Component({})
export class ParentComponent {
  private configs = [...];

  constructor(private filterService: FilterPanelService) {
    this.filterValues$ = this.filterService.filterValues$;

    // Initialise filter service in parent
    this.filterService.init(this.configs);
  }
}

@Component({})
export class FilterPanelComponent implements OnInit {
  @Input()
  configs: FilterConfig[];

  ngOnInit() { 
      //No need to initialise the filter service here

      //Setup filter inputs based on configs
  }
}

//No changes required
export class FilterService<T> {}



Enter fullscreen mode Exit fullscreen mode

No feedback loop anymore

Success! We have no exceptions, no bugs, no work around code and no performance degradation. We have also reduced the risk of future bugs as we no longer have a mysterious delay(0) in our code. I bet in a years time someone, (possibly me) will think what's the point of that and delete it creating a bug.

Moral of the story? Think about your data flows and don't just reach for setTimeout every time you run into the expression has changed after it was checked error.


Stephen Cooper - Senior Developer at AG Grid
Follow me on Twitter @ScooperDev or Tweet about this post.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .