Beware of Optimized Angular Change Detection

August 3rd 2018 Angular

Updating of views in Angular is fully dependent on its change detection. I've already written a post on how code executed outside NgZone can be missed by change detection. But Angular's highly optimized change detection code can bite you in other scenarios as well.

The Setup

The following contrived example reproduces an issue I recently troubleshooted in a much larger and more complex code base:

  • The code interacts with a remote REST service which I simulate here with a local service. The service keeps a persistent state. Just a timestamp of the last successful call is enough for our needs. Each successful call returns the updated state. A failed call throws an error instead. There's a separate method for getting the current state. To simulate a remote call, it creates a copy of the state for the caller by serializing and deserializing it before returning it.

      import { Injectable } from '@angular/core';
      import { State } from './state';
    
      @Injectable({
        providedIn: 'root'
      })
      export class RemoteService {
    
        private state: State = {
          createdAt: Date.now(),
          updatedAt: Date.now()
        };
    
        getState(): State {
          // return a different instance
          return JSON.parse(JSON.stringify(this.state));
        }
    
        updateState(succeed: boolean): State {
          if (succeed) {
            this.state.updatedAt = Date.now();
            return this.getState();
          } else {
            throw Error('Failed');
          }
        }
      }
    
  • All interaction with the remote service is taken care of by the ApiService. It adds some functionality of its own on top of passing the calls through to the RemoteService. It persists the state locally so that it can return it even when the call fails. It also tracks the errorCount and has a method for retrieving its value.

      import { Injectable } from '@angular/core';
      import { State } from './state';
      import { RemoteService } from './remote.service';
    
      @Injectable({
        providedIn: 'root'
      })
      export class ApiService {
    
        private errorCount = 0;
    
        private state: State;
    
        constructor(private remoteService: RemoteService) {
          this.state = this.remoteService.getState();
        }
    
        getErrorCount(): number {
          return this.errorCount;
        }
    
        getState(): State {
          return this.state;
        }
    
        updateState(succeed: boolean): State {
          try {
            this.state = this.remoteService.updateState(succeed);
            return this.state;
          } catch (error) {
            this.errorCount++;
            return this.state;
          }
        }
      }
    
  • There's a component in the app for rendering the state including the errorCount.

      <div>Created at: {{ state.createdAt | date: 'mediumTime' }} </div>
      <div>Updated at: {{ state.updatedAt | date: 'mediumTime' }} </div>
      <div>Errors: {{ errorCount }}</div>
    
  • The state is exposed as a component input variable. It's implemented as a property which also retrieves the errorCount in its setter, i.e. whenever a value is assigned to the input variable.

      import { State } from './../state';
      import { Component, OnInit, Input } from '@angular/core';
      import { ApiService } from '../api.service';
    
      @Component({
        selector: 'app-state',
        templateUrl: './state.component.html',
        styleUrls: ['./state.component.css']
      })
      export class StateComponent {
    
        errorCount: number;
    
        private stateValue: State;
        @Input()
        get state(): State {
          return this.stateValue;
        }
        set state(value: State) {
          this.stateValue = value;
          this.errorCount = this.apiService.getErrorCount();
        }
    
        constructor(private apiService: ApiService) { }
      }
    
  • To reproduce the issue, the main AppComponent binds its state variable to the StateComponent and includes two buttons for invoking the remote method, resulting either in failure or success.

      <div>
        <app-state [state]="state"></app-state>
        <button (click)="update(true)">Update</button>
        <button (click)="update(false)">Update with error</button>
      </div>
    
  • Its code simply calls the ApiService and stores the latest state locally so that it can be bound to the StateComponent.

      import { Component } from '@angular/core';
      import { State } from './state';
      import { ApiService } from './api.service';
    
      @Component({
        selector: 'app-root',
        templateUrl: './app.component.html',
        styleUrls: ['./app.component.css']
      })
      export class AppComponent {
    
        state: State;
    
        constructor(private apiService: ApiService) {
          this.state = this.apiService.getState();
        }
    
        update(succeed: boolean) {
          this.state = this.apiService.updateState(succeed);
        }
      }
    

The Problem

Here's how the issue manifests itself:

  • When clicking the button which calls the remote method successfully, the view updates as expected: on every click the Updated at value is set to current time.
  • However, when clicking the button which calls the remote method with failure, the view doesn't update: the Errors counter doesn't increment. Only when the method for the successful remote method call is clicked, the view updates showing the correct value for both the Updated at timestamp and the Errors counter.

If we were to debug the application, we would see that the state property setter in the StateComponent is not invoked when the remote method call fails, although we explicitly set the value to the state variable in the main AppComponent. Because Angular's change detection determines that the same value has been assigned, it doesn't propagate the change to the StateCcomponent, in effect breaking the intended functionality.

The Solution

So, how can this be fixed? In my opinion, it's best to include the errorCount value in the local state.

  • To extend the State type I'll use the intersect type approach I explained in a previous blog post.

      export interface State {
        createdAt: number;
        updatedAt: number;
      }
    
      export interface WithErrorCount {
        errorCount: number;
      }
    
  • The changes will mostly be in the ApiService which needs to compose the correct state whenever either the remote state or the local errorCount changes. I'm using properties to hide the details from the methods updating either value.

      import { Injectable } from '@angular/core';
      import { State, WithErrorCount } from './state';
      import { RemoteService } from './remote.service';
    
      @Injectable({
        providedIn: 'root'
      })
      export class ApiService {
    
        private errorCountValue = 0;
        private get errorCount(): number {
          return this.errorCountValue;
        }
        private set errorCount(value: number) {
          this.errorCountValue = value;
          if (this.stateValue != null) {
            this.stateValue.errorCount = this.errorCountValue;
          }
        }
    
        private stateValue: State & WithErrorCount;
        private get state(): State {
          return this.stateValue;
        }
        private set state(value: State) {
          this.stateValue = value as State & WithErrorCount;
          if (this.stateValue != null) {
            this.stateValue.errorCount = this.errorCount;
          }
        }
    
        constructor(private remoteService: RemoteService) {
          this.state = this.remoteService.getState();
        }
    
        getState(): State & WithErrorCount {
          return this.stateValue;
        }
    
        updateState(succeed: boolean): State & WithErrorCount {
          try {
            this.state = this.remoteService.updateState(succeed);
            return this.stateValue;
          } catch (error) {
            this.errorCount++;
            return this.stateValue;
          }
        }
      }
    
  • The StateComponent doesn't need to care about the specifics of errorCount any more. This simplifies the code a lot.

      import { State, WithErrorCount } from './../state';
      import { Component, OnInit, Input } from '@angular/core';
    
      @Component({
        selector: 'app-state',
        templateUrl: './state.component.html',
        styleUrls: ['./state.component.css']
      })
      export class StateComponent {
    
        @Input()
        state: State & WithErrorCount;
      }
    
  • Its template of course needs to read the errorCount value directly from the state now.

      <div>Created at: {{ state.createdAt | date: 'mediumTime' }} </div>
      <div>Updated at: {{ state.updatedAt | date: 'mediumTime' }} </div>
      <div>Errors: {{ state.errorCount }}</div>
    
  • The main AppComponent only needs to have the type of the state variable updated accordingly.

      import { Component } from '@angular/core';
      import { State, WithErrorCount } from './state';
      import { ApiService } from './api.service';
    
      @Component({
        selector: 'app-root',
        templateUrl: './app.component.html',
        styleUrls: ['./app.component.css']
      })
      export class AppComponent {
    
        state: State & WithErrorCount;
    
        constructor(private apiService: ApiService) {
          this.state = this.apiService.getState();
        }
    
        update(succeed: boolean) {
          this.state = this.apiService.updateState(succeed);
        }
      }
    

With these changes in place, the application now works as expected. Whichever button is clicked, the view is immediately updated as expected: either the Updated at timestamp or the Errors counter changes. I'd argue the new code is easier to understand as well.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License