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 thestate
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 theRemoteService
. It persists thestate
locally so that it can return it even when the call fails. It also tracks theerrorCount
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 theerrorCount
.<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 theerrorCount
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 itsstate
variable to theStateComponent
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 lateststate
locally 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
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 correctstate
whenever either the remotestate
or the localerrorCount
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 oferrorCount
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 thestate
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 thestate
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.