Introducing a Custom set Function for linkedSignal in Angular 22.1
Angular 22.1 brings a notable enhancement: a custom set function for linkedSignal. This feature essentially enables a form of two-way binding between signals, while maintaining a single source of truth.
The Angular 22.1 Release
This release marks the first minor version within the Angular 22 series. Given Angular's annual major release schedule, a series of minor updates is anticipated throughout the year.
As a minor version, 22.1 adds functionalities without introducing breaking changes to existing projects. The headline feature covered here is the custom set function for linkedSignal.
Previously, linkedSignal was limited to deriving a writable value from a source signal. With the new custom setter, writing to the linked value can now also propagate changes back to the source signal.
This capability facilitates a two-way binding-like interaction between signals. For detailed information, see the Angular 22.1 release notes.
Implementing Two-Way Currency Conversion
Let's illustrate this with a currency converter that has two input fields: one for euros and one for dollars.
If a user enters a value in euros, the dollar field should update instantly. Conversely, modifying the dollar amount should recalculate the euro value without delay.
In this setup, the euro signal is the authoritative source. The dollar signal is linked to it and computes its value based on the exchange rate.
Before Angular 22.1, handling the reverse direction (from dollars back to euros) required an effect. This effect would watch for changes to the dollar value and then push the converted amount back to the euro signal.
The Previous Approach: Using an effect for Synchronization
import { Component, effect, linkedSignal, signal, untracked } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
@Component({
selector: 'app-root',
imports: [FormField],
template: `
<main class="mx-auto mt-16 grid max-w-md gap-4 p-4">
<h1 class="text-2xl font-semibold">EUR ↔ USD</h1>
<label class="grid gap-1 text-sm">
EUR
<input class="rounded border p-2" [formField]="eurForm" type="number" step="0.01" />
</label>
<label class="grid gap-1 text-sm">
USD
<input class="rounded border p-2" [formField]="usdForm" type="number" step="0.01" />
</label>
<button
class="cursor-pointer rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
(click)="resetEur()"
>
Set USD to 100
</button>
</main>
`,
})
export class App {
protected readonly eurValue = signal(0);
protected readonly eurForm = form(this.eurValue, (path) => required(path));
protected readonly usdValue = linkedSignal({
source: this.eurValue,
computation: (eurValue) => this.#toMoney(eurValue * 1.16),
});
protected readonly usdForm = form(this.usdValue, (path) => required(path));
constructor() {
effect(() => {
const usd = this.usdForm().value();
untracked(() => {
const eur = this.#toMoney(usd / 1.16);
if (this.eurValue() !== eur) {
this.eurValue.set(eur);
}
});
});
}
resetEur() {
this.usdValue.set(100);
console.log(`USD ${this.usdValue()} are in EUR ${this.eurValue()}`);
}
#toMoney(value: number) {
return Math.round(value * 100) / 100;
}
}
The forward conversion is elegantly handled by linkedSignal: a change to eurValue triggers the computation, which updates the dollar value synchronously.
The reverse path, however, involves more complexity. An effect is needed to watch the dollar form. The untracked function is used to ensure that writing back to eurValue does not create a circular dependency. Additionally, an equality check is implemented to prevent unnecessary value updates.
A key difference is that effects run asynchronously. If you set the dollar value and then immediately try to read the euro value, you might still encounter the old euro value until the effect has had a chance to execute.
The New Approach: Writing Back with linkedSignal
Angular 22.1 introduces the ability to define a custom behavior for when set or update is called on a linkedSignal. This allows the write-back logic to be declared in the same place as the computation logic.
import { Component, linkedSignal, signal } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
@Component({
selector: 'app-root',
imports: [FormField],
template: `
<main class="mx-auto mt-16 grid max-w-md gap-4 p-4">
<h1 class="text-2xl font-semibold">EUR ↔ USD</h1>
<label class="grid gap-1 text-sm">
EUR
<input class="rounded border p-2" [formField]="eurForm" type="number" step="0.01" />
</label>
<label class="grid gap-1 text-sm">
USD
<input class="rounded border p-2" [formField]="usdForm" type="number" step="0.01" />
</label>
<button
class="cursor-pointer rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
(click)="resetEur()"
>
Set USD to 100
</button>
</main>
`,
})
export class App {
protected readonly eurValue = signal(0);
protected readonly eurForm = form(this.eurValue, (path) => required(path));
protected readonly usdValue = linkedSignal({
source: this.eurValue,
computation: (eurValue) => this.#toMoney(eurValue * 1.16),
set: (value) => this.eurValue.set(this.#toMoney(this.usdValue() / 1.16)),
});
protected readonly usdForm = form(this.usdValue, (path) => required(path));
resetEur() {
this.usdValue.set(100);
console.log(`USD ${this.usdValue()} are in EUR ${this.eurValue()}`);
}
#toMoney(value: number) {
return Math.round(value * 100) / 100;
}
}
This new approach removes the need for the effect, untracked, and the constructor. The linkedSignal now manages both directions of data flow:
- The
computationfunction updates the dollar value whenever the euro source signal changes. - The
setfunction is responsible for handling explicit writes to the dollar signal and for pushing the converted value back to the euro source. - Updating the source signal subsequently triggers a synchronous recomputation of the linked value.
The outcome is a more concise implementation where the mapping of reads and writes is defined in a single location.
Clarifying the Role of a Custom Setter
The custom set function is invoked whenever there is an explicit write operation to the linked signal. This makes it powerful enough to execute additional logic and to respond to multiple synchronous writes, each on its own.
While it's technically possible to use this to circumvent the glitch-free behavior of an effect, that is not the recommended use case. The custom setter is designed to be most effective when it clearly describes how a change to derived state should be reflected in its source of truth.
