Explain toSignal custom equality checking in Angular 18

Reading Time: 4 minutes

Loading

Introduction

In this blog post, I want to describe the toSignal custom equality checking that Angular team released in version 18.1.0. toSignal supports an equal option where developers can pass in a function to determine whether or not two signal values are the same. The equal function was supported by the built-in signal when Angular 16 first introduced it. Now, the option is available in toSignal and developers can use it to control when to push changes to downstream computed signals to improve performance.

let's go

Custom equality check in signal

Before I explain the toSignal custom equality checking in detail, I would like to demonstrate how the signal function does it.

// name.type.ts

export type Name = {
  name: string;
}
// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [],
  template: `
    <p>aSignal - {{ a().name }}</p>
    <p>aStarSignal - {{ aStar() }}</p>
    <p>trigger - {{ trigger }}</p>
    <p>Click Set to John button does not trigger computed signal because john equals to John based on the function</p>
    <hr />
    <button (click)="a.set({name: 'John' })">Set to John</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
  a = signal<Name>({ name: 'john' }, {
    equal: (a: Name, b: Name) => a.name.toLowerCase() === b.name.toLowerCase(),
  });

  trigger = 0;
  aStar = computed(() => {
    this.trigger = this.trigger + 1;
    return `${this.a().name}*`;
  });
}

The signal holds a Name object, which uses a function to compare the value between two names. If the equality function is unprovided, the default implementation compares object references instead, which can lead to extra computations in computed signals.

When a user clicks the button to set the signal to { name: 'John' }, the equal function returns true because the current and next values have the same lowercase values. Therefore, neither the original nor computed signals update.

Let’s repeat the same exercise with toSignal custom equality checking.

Observable to Signals conversion by toSignal interop function

// app.service.ts

import { Injectable } from "@angular/core";
import { Subject } from "rxjs";
import { Name } from "./name.type";
import { toSignal } from "@angular/core/rxjs-interop";

const defaultOptions = {
  initialValue: { name: 'John' } as Name,
}

@Injectable({
  providedIn: 'root'
})
export class AppService {
  private readonly nameSub = new Subject<Name>();

  setName(newName: Name) {
    this.nameSub.next(newName);
  }

  nameSignal = toSignal(this.nameSub, {
    ...defaultOptions,
    equal: (a: Name, b: Name) => a.name === b.name,
  });

  nameDefaultSignal = toSignal(this.nameSub,defaultOptions);
}

AppService has a nameSub subject to hold the value of Name object. The service has two signals, nameSignal and nameDefaultSignal, that use the toSignal function to convert the Observable to a signal. The initial value of the signals is { name: 'John' }. However, nameSignal has a custom equality function that compares the name between the current and next signal values.

// name-signal.component.ts

import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
import { Name } from "./name.type";

@Component({
  selector: 'app-name-signal',
  standalone: true,
  template: `
    <h2><ng-content header>toSignal default equality check</ng-content></h2>
    <p>nameSignal - {{ name().name }}</p>
    <p>Upper Name: {{ upperName() }}</p>
    <p>trigger - {{ trigger }}</p>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class NameSignalComponent {
  name = input.required<Name>();

  trigger = 0;
  upperName = computed(() => {
    this.trigger = this.trigger + 1;
    return (this.name().name).toUpperCase();
  })
}

This component accepts a Signal input and declares a computed signal, upperName, which converts the name to uppercase. I increment the trigger instance member in the callback function to count the number of computations. The template then displays the Signal input, computed signal, and trigger to show that the computed signal is derived whenever the signal receives a different name.

Use custom equality checking in toSignal

// main.ts

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { AppService } from './app.service';
import { NameSignalComponent } from './name-signal.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [NameSignalComponent],
  template: `
    <app-name-signal [name]="nameSignal()">
      <ng-container select="header">toSignal custom equality check</ng-container>
    </app-name-signal>
    <hr />
    <app-name-signal [name]="nameDefaultSignal()" />
    <hr />
    
    <button (click)="setName('Jane')">Set to Jane</button>
    <button (click)="setName('John')">Set to John</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
  appService = inject(AppService);
  nameSignal = this.appService.nameSignal;
  nameDefaultSignal = this.appService.nameDefaultSignal;

  setName(name: string) {
    this.appService.setName({ name });
  }
}

The AppComponent has two buttons that update the name subject to Jane and John respectively. When a user clicks the ‘Set to Jane’ button for the first time, nameSignal and nameDefaultSignal are set to ‘Jane’ and push the changes to the computed signals. Moreover, the number of triggers increases by one.

When subsequent clicks happen, nameSignal‘s equal function returns true and does not push the change to the computed signal. Therefore, the number of triggers does not increase.

When a user clicks the ‘Set to John’ button, nameSignal and nameDefaultSignal change from ‘Jane’ to ‘John’ and push the changes to the computed signals. Similarly, both NameSignalComponent components increase the trigger by one.

When subsequent clicks happen, nameSignal‘s equal function returns true and does not push the change to the computed signal. Therefore, the number of triggers does not change.

Default equality checking

On the other hand, nameSignalDefault uses the default implementation and compares the object references. The setName method constructs a new Name object and feeds it to the name subject. nameSignalDefault‘s equal function evaluates to false because it checks the references and ignores the internal value. Then, the computed signal runs the callback function, increases the trigger instance member, and displays the value in the template.

The following Stackblitz repo displays the final results:

This is the end of the blog post that introduce toSignal custom equality check in Angular 18. I hope you like the content and continue to follow my learning experience in Angular, NestJS, GenerativeAI, and other technologies.

Resources:

  1. Stackblitz Demo: https://stackblitz.com/edit/stackblitz-starters-e6c3yd?file=src%2Fmain.ts
  2. Github Repo: https://github.com/railsstudent/ng-toSignal-equality-check-demo
  3. Github Page: https://railsstudent.github.io/ng-toSignal-equality-check-demo/