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 built-in signal supported the equal function when Angular 16 was first introduced. 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.
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.