Beware of Optimized Angular Change Detection
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 updatedstate. A failed call throws an error instead. There's a separate method for getting the currentstate. To simulate a remote call, it creates a copy of thestatefor 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 theRemoteService. It persists thestatelocally so that it can return it even when the call fails. It also tracks theerrorCountand 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
stateincluding theerrorCount.<div>Created at: {{ state.createdAt | date: 'mediumTime' }} </div> <div>Updated at: {{ state.updatedAt | date: 'mediumTime' }} </div> <div>Errors: {{ errorCount }}</div>The
stateis exposed as a component input variable. It's implemented as a property which also retrieves theerrorCountin 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
AppComponentbinds itsstatevariable to theStateComponentand 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
ApiServiceand stores the lateststatelocally so that it can be bound to theStateComponent.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
Statetype 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
ApiServicewhich needs to compose the correctstatewhenever either the remotestateor the localerrorCountchanges. 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
StateComponentdoesn't need to care about the specifics oferrorCountany 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
errorCountvalue directly from thestatenow.<div>Created at: {{ state.createdAt | date: 'mediumTime' }} </div> <div>Updated at: {{ state.updatedAt | date: 'mediumTime' }} </div> <div>Errors: {{ state.errorCount }}</div>The main
AppComponentonly needs to have the type of thestatevariable 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.
