How Immutable Data Can Make Your Ionic Apps Faster

12 min read

Originally published April 20, 2021

If you are not already using immutable data concepts, you might have seen all the cool kids updating their arrays like this:

public myArray: number[] = [1,2,3];

ngOnInit(){
  this.myArray = [...this.myArray, 4];
}

instead of like this:

public myArray: number[] = [1,2,3];

ngOnInit(){
  this.myArray.push(4);
}

The first example creates a new array by using the values of the old array with an additional 4 element on the end. The second example just pushes 4 into the existing array. Apart from being a chance to show off cool JavaScript operators like the spread operator, there are practical benefits to using the first approach.

The word immutable just means "not changeable" or "can not be mutated". If you have an "immutable" object you can not change the values of that object. If you want new values, you need to create an entirely new object in memory. We might enforce immutability ourselves by just following some rules, or we might use libraries like immer or Immutable.js to help enforce immutability in our application. This is similar to the philosophy behind why we might use something like TypeScript to help enforce certain rules in our application.

Outline

What's the point?

It might just seem like more work to create new objects instead of modifying existing ones. Indeed it is, especially when you are dealing with nested objects it can be quite difficult to update a value in an immutable way.

There are benefits to using immutable data in general, but we are going to focus on one specific example of where using immutable data can help us improve the performance of our Ionic/Angular applications.

In short: we can supply an Angular component with the OnPush change detection strategy with immutable data to greatly reduce the amount of work Angular needs to do during its change detection cycles.

Change Detection in Angular (in Brief)

In Angular, we bind data values in our templates. When that data is updated, we want the template to reflect those changes. The basic idea with change detection is that we handle updating the data, and Angular will handle reflecting that in our templates for us.

If Angular want to update a template, it needs to know that something has changed. To keep track of this, Angular will know when any of the following events have been triggered within its zone:

  • Any browser event (like click)
  • A setTimeout callback
  • A setInterval callback
  • An HTTP request

There are all things that might result in some data changing. Angular will detect when any of these occur and then check the entire component tree for changes. That means that Angular will start at the root component, and make its way through every component in the application to check if the data any of those components rely on has changed.

This probably sounds worse than it is. Change detection in Angular is fast... but it's not free. The larger your application grows the more work there will be for Angular to do during change detection cycles. We can reduce the amount of work Angular has to do but there are some important concepts we need to keep in mind when doing this.

Using the OnPush Change Detection Strategy

This is where the OnPush change detection strategy comes into play. There are two types of change detection strategies that Angular can use:

  • The Default strategy (which we just discussed)
  • The OnPush strategy

We can tell Angular to use the OnPush strategy by adding the changeDetection property to the components metadata:

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

@Component({
  selector: 'app-news-feed-item',
  templateUrl: './news-feed-item.component.html',
  styleUrls: ['./news-feed-item.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})

The key difference with this strategy is that if change detection is triggered somewhere in the application, this component will not be checked by default. It will only be checked under more specific circumstances. If we use the OnPush change detection strategy with as many components as we can, we can greatly reduce the amount of components Angular needs to check.

The primary use case that we are interested in is that the component will be checked for changes when:

  • The reference supplied to any of the component's inputs has changed

There are other circumstances where change detection can be triggered but we will get to that in a moment. Let's continue with our NewsFeedItemComponent example. We might use that component like this:

<ion-header>
  <ion-toolbar>
    <ion-title>OnPush Strategy</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <app-news-feed-item [article]="article"></app-news-feed-item>
  <app-news-feed-item [article]="article"></app-news-feed-item>
  <app-news-feed-item [article]="article"></app-news-feed-item>
  <app-news-feed-item [article]="article"></app-news-feed-item>
</ion-content>

The corresponding component for <app-news-feed-item> looks like this:

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Article } from '../../../interfaces/article';

@Component({
  selector: 'app-news-feed-item',
  templateUrl: './news-feed-item.component.html',
  styleUrls: ['./news-feed-item.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NewsFeedItemComponent {
  @Input() article: Article;

  constructor() {}
}

We are supplying an article to the component as an input. Let's suppose that our article that is being supplied to the component looks like this:

public article: Article = {
  headline: 'This one trick will speed up your Ionic apps',
  author: 'Josh Morony',
  content: 'OnPush rocks',
};

Our NewsFeedItemComponent just displays this data in its template. But now we want to update this article and, of course, we want our <app-news-feed-item> component to reflect that in its template. No problem, we will just call our handy updateArticle method (we might trigger this method by handling a (click) event on a button in the template):

updateArticle(){
  this.article.headline = 'Using the OnPush change detection strategy';
}

The object that we are using as an input for NewsFeedItemComponent has changed, so change detection should be triggered and the change will be reflected in the template right? Let's look at our rule about when change detection will be triggered again

  • The reference supplied to any of the component's inputs has changed

This rule has not been satisfied, and this is why we should use immutable data. We have just modified the existing object, but it still has the same reference in memory. With the OnPush change detection strategy Angular will just compare references, it won't perform a deep check of the values of the object to see if there has been any changes. In this case, the reference remains the same even after we updated the headline property because this.article still occupies the same space in memory.

If we were using the Default change detection strategy, change detection would be triggered even if the same reference was maintained through the object just being mutated, but not with OnPush.

To correctly update the input we can make use of those immutable data concepts. If instead we update the article like this:

updateArticle(){
  this.article = {
    ...this.article,
    headline: 'Using the OnPush change detection strategy'
  }
}

A new object will be created in memory and therefore it will have a new reference. When Angular compares the current input to the previous input it will see that the references do not match, and change detection will be triggered.

When else will change detection be triggered?

There are some other cases where a component using the OnPush strategy will have change detection triggered. Let's look at a more complete list:

  • When the reference for the components inputs change
  • When the component itself or any of its children fire an event
  • An observable in the template using the | async pipe emits a value

This last one is especially interesting and useful. It is quite common for a component to get some value by subscribing to an observable provided by a service. Let's say we have a service that looks something like this:

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class DataService {
  private randomNumbers$: BehaviorSubject<number> = new BehaviorSubject<number>(
    0
  );

  constructor() {
    this.init();
  }

  init() {
    setInterval(() => {
      this.randomNumbers$.next(Math.ceil(Math.random() * 100));
    }, 1000);
  }

  getRandomNumbers(): Observable<number> {
    return this.randomNumbers$;
  }
}

It has a BehaviorSubject that is emitting random numbers, and we have a getRandomNumbers method that will return this BehaviorSubject as an observable.

TIP: Giving the getRandomNumbers method a return type of Observable<number> instead of BehaviorSubject<number> will help prevent consumers of getRandomNumbers from calling next on the underlying BehaviorSubject. This allows us to give out the values from this observable, but prevent other parts of the application from changing the current value of randomNumbers$ (this keeps behaviour more consistent as we know new values for randomNumbers$ will always originate from within this service).

We might try to make use of these values in our NewsFeedItemComponent:

import {
  ChangeDetectionStrategy,
  Component,
  Input,
  OnInit,
  OnDestroy,
} from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { DataService } from '../../../services/data.service';
import { Article } from '../../../interfaces/article';

@Component({
  selector: 'app-news-feed-item',
  templateUrl: './news-feed-item.component.html',
  styleUrls: ['./news-feed-item.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NewsFeedItemComponent implements OnInit, OnDestroy {
  @Input() article: Article;
  public randomNumber = 0; 

  private subscription: Subscription;

  constructor(public dataService: DataService) {}

  ngOnInit() {
    // Setting the `randomNumber` like this won't trigger change detection
    this.subscription = this.dataService
      .getRandomNumbers()
      .subscribe((value) => {
        this.randomNumber = value;
      });
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }

}

and we might want to display that randomNumber in the template:

<p>{{ randomNumber }}</p>

The only problem here is that if we are using the OnPush change detection strategy, change detection will not be triggered. None of our rules for triggering change detection have been met:

  • No inputs references for the component have changed
  • No events have been fired within the component (or its children of which it has none)
  • We are not using the async pipe in the template

However, if we refactor this example a bit we can trigger change detection... and save ourselves a whole bunch of work. Instead of subscribing to the observable and updating a class member, we could instead supply the observable directly to the template and use the async pipe:

import {
  ChangeDetectionStrategy,
  Component,
  Input,
  OnInit,
  OnDestroy,
} from '@angular/core';
import { Observable } from 'rxjs';
import { DataService } from '../../../services/data.service';
import { Article } from '../../../interfaces/article';

@Component({
  selector: 'app-news-feed-item',
  templateUrl: './news-feed-item.component.html',
  styleUrls: ['./news-feed-item.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NewsFeedItemComponent implements OnInit, OnDestroy {
  @Input() article: Article;
  public randomNumber$: Observable<number> | null;

  constructor(public dataService: DataService) {}

  ngOnInit() {
    this.randomNumber$ = this.dataService.getRandomNumbers();
  }
}
<p>{{ randomNumber$ | async }}</p>

Now not only will change detection be triggered when new random numbers are emitted, we also don't need to worry about subscribing or unsubscribing from the observable. The async pipe will automatically subscribe to the observable for us and pull out its values and it will automatically unsubscribe when the component is destroyed. This is a win, win, win situation for us:

  • Trigger change detection
  • No subscribe code required
  • No unsubscribe code required

Summary

Using the OnPush change detection strategy without having a decent understanding of how it works could lead to some pretty frustrating bugs, but if you understand when change detection will be triggered it is a pretty easy win for performance.

In general, just make sure to use immutable data for your inputs and use the async pipe for getting values from observables that need to be displayed in your template.