Angular 22.1 adds a custom set function for linkedSignal. It gives us something like two-way binding between signals while keeping one signal as the source of truth.
Angular 22.1
Angular 22.1 is the first minor release in the Angular 22 range. With Angular's yearly major release cycle, we can expect multiple minor releases during the year.
As a minor release, 22.1 can introduce new features without breaking existing applications. The main feature for this episode is the custom set function of linkedSignal.
Until now, linkedSignal could derive a writable value from another signal. With the custom setter, writing to that linked value can also update the source signal.
That gives us something like two-way binding between signals.
A currency converter in both directions
Consider a currency converter with two input fields: one for euros and another for dollars.
When the user changes the euro value, the dollar value should update immediately. The same should work in the other direction: changing dollars should update euros.
The euro signal remains the source of truth. The dollar signal is linked to it and derives its value using the exchange rate.
Before Angular 22.1, the other direction required an effect. The effect observed changes to the dollar value and wrote the converted result back to the euro signal.
Before: synchronizing with an effect
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 direction already belongs to linkedSignal: whenever eurValue changes, the computation derives the dollar value synchronously.
The reverse direction needs more machinery. The effect observes the dollar form, untracked prevents the write to eurValue from becoming another dependency, and the equality check avoids an unnecessary update.
There is another difference: effects run asynchronously. If code sets the dollar value and reads the euro value immediately afterwards, it can still see the old euro value until the effect runs.
After: writing back with linkedSignal
Angular 22.1 allows linkedSignal to define what should happen when someone calls set or update on it. The write-back logic can therefore live directly beside the computation.
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;
}
}
The effect, untracked, and constructor disappear. The linkedSignal now owns both directions:
-
computationderives dollars whenever the euro source changes. -
sethandles explicit writes to the dollar signal and writes back to the euro source. - The source update causes the linked value to be recomputed synchronously.
The result is a smaller implementation in which the read and write relationship is declared in one place.
A custom setter is not a general-purpose effect
The custom set function runs whenever code explicitly writes to the linked signal. That makes it powerful enough to execute other logic and react to multiple synchronous writes individually.
Technically, that could be used to work around the glitch-free behavior of an effect. But that is not the intended pattern. The custom setter is most useful when it expresses how a write to derived state should update its source of truth.
Top comments (0)