The new LinkedSignal feature introduced in Angular 19 provides a powerful mechanism for managing reactive state by allowing a signal to be directly linked to a source value. The LinkedSignal creates a WritableSignal; therefore, developers can set the value explicitly or update it when the source changes, facilitating a seamless synchronization between the two.
This blog post illustrates four examples to show the capabilities of LinkedSignal
- The source of the LinkedSignal is the page number that gets updated when the source changes. When the source exceeds the maximum number, the LinkedSignal reverts to the previous value.
- The LinkedSignal has a shorthand version that returns the value based on the source. Moreover, we can set and update the source and the LinkedSignal independently.
- The source of the LinkedSignal is an array of numbers. When the source changes to a different array and the value does not exist, the LinkeSignal resets to the first item of the array.
- The last demo is the rewrite of the third demo, where the service encapsulates the LinkedSignal. Since a LinkedSignal is a WritableSignal, it can return a Signal by calling the asReadOnly method and returning it to the component for display.
Demo 1: Create a LinkedSignal with source and computation
<h2>Update the shorthand version of the linked signal</h2>
<div>
<button (click)="pageNumber.set(1)">First</button>
<button (click)="changePageNumber(-1)">Prev</button>
<button (click)="changePageNumber(1)">Next</button>
<button (click)="pageNumber.set(lastPage)">Last</button>
<p>Go to: <input type="number" [(ngModel)]="pageNumber" /></p>
</div>
<p>Page Number: {{ pageNumber() }}</p>
<p>Current Page Number {{ currentPageNumber() }}</p>
<p>Percentage of completion: {{ percentageOfCompletion() }}</p>
The template has four buttons that set the pageNumber
signal to 1, decrease the signal by 1, increase the signal by 1, and set the signal to 200. The number input directly writes the value to the same signal. The template also displays the value of the pageNumber
signal, currentPageNumber
linked signal, and the percentableOfCompletion
computed signal.
import { ChangeDetectionStrategy, Component, computed, linkedSignal, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
const LAST_PAGE = 200;
@Component({
selector: 'app-pagination',
standalone: true,
imports: [FormsModule],
template: `
<h2>Update the shorthand version of the linked signal</h2>
<div>
<button (click)="pageNumber.set(1)">First</button>
<button (click)="changePageNumber(-1)">Prev</button>
<button (click)="changePageNumber(1)">Next</button>
<button (click)="pageNumber.set(lastPage)">Last</button>
<p>Go to: <input type="number" [(ngModel)]="pageNumber" /></p>
</div>
<p>Page Number: {{ pageNumber() }}</p>
<p>Current Page Number {{ currentPageNumber() }}</p>
<p>Percentage of completion: {{ percentageOfCompletion() }}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export default class PaginationComponent {
lastPage = LAST_PAGE;
pageNumber = signal(1)
currentPageNumber = linkedSignal<number, number>({
source: this.pageNumber,
computation: (pageNumber, previous) => {
if (!previous) {
return pageNumber;
}
return (pageNumber < 1 || pageNumber > LAST_PAGE) ? previous.value : pageNumber
}
});
percentageOfCompletion = computed(() => `${((this.currentPageNumber() * 1.0) * 100 / LAST_PAGE).toFixed(2)}%`);
changePageNumber(offset: number) {
this.pageNumber.update((value) => Math.max(1, Math.min(LAST_PAGE, value + offset)));
}
}
The source of currentPageNumber
is the pageNumber
signal, which computes the new value when the source changes. The computation
property is a function that accepts the page number and the previous
Object. When the previous
Object is undefined, the linked signal returns the page number. When the page number is out of range, then the linked signal returns the previous page number or previous.value
.
In the demo, I can input 201 to bind the value to the pageNumber
signal, but the currentPageNumber
reverts to the previous value.
Moreover, computed signals can derive from a LinkedSignal because it is also a WritableSignal. The percentageOfCompetation
computed signal derives from the currentPageNumber
linked signal to calculate the percentage and convert it to a string.
Demo 2: Create a shorthand version of the LinkedSignal
<h2>Update the shorthand version of the linked signal. Set and update the signal</h2>
<p>Update country: <input [(ngModel)]="country" /></p>
<p>Update favorite country: <input [(ngModel)]="favoriteCountry" /></p>
<button (click)="country.set('United States of America')">Reset</button>
<button (click)="changeCountry()">Update source and linked signal</button>
<p>Country: {{ country() }}</p>
<p>Favorite Country: {{ favoriteCountry() }}</p>
<p>Reversed Country: {{ reversedFavoriteCountry() }}</p>
The template has two HTML input elements. The first input field binds to the country
signal, while the second binds to the favoriteCountry
LinkedSignal. When the button resets the country
signal, it also resets the favoriteCountry
. The other button calls the changeCountry
function to write to the country
signal, and the favoriteCountry
LinkedSignal directly. Then, we display the signals to see the different values after each action.
// favorite-country.component.ts
import { ChangeDetectionStrategy, Component, computed, linkedSignal, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-favorite-country',
standalone: true,
imports: [FormsModule],
template: `... inline template ...`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export default class FavoriteCountryComponent {
country = signal('United States of America')
favoriteCountry = linkedSignal(() => this.country());
reversedFavoriteCountry = computed(() => this.favoriteCountry().split('').toReversed().join(''));
changeCountry() {
// update the source of the linked signal
this.country.set('Canada');
// updated the linked signal because it is a writable signal
this.favoriteCountry.update((c) => c.toUpperCase());
}
}
favoriteCountry = linkedSignal(() => this.country());
This is the LinkedSignal shorthand that returns the country signal’s value. When I input different countries in the first HTML input, country
and favoriteCountry
are updated. Moreover, the second input displays the latest value of the favoriteCountry
.
When I input different countries into the second HTML input, only the favoriteCountry
is updated, and country
is not impacted. At that time, the LinkedSignal and the source hold different values.
In both cases, the reversedFavoriteCountry
displays the favoriteCountry in reverse order.
When I click the button to invoke the changeCountry
method, I set the country
signal to “Canada” and trigger the favoriteCountry
LinkedSignal to update. LinkedSignal is also a WritableSignal; I can call the update method to convert the favoriteCountry
signal to uppercase. Therefore, the value of the country
is “Canada”, favoriteCountry
is “CANADA” and reversedFavoriteCountry
is “ADNAC”.
Demo 3: Reset/retain the element when the source array changes
<h2>Reset linked signal after updating source</h2>
<p>Source: {{ shoeSizes() }}</p>
<p>Shoe size: {{ currentShoeSize() }}</p>
<p>Shoe index: {{ index() }}</p>
<div>
<button (click)="changeShoeSizes()">Update shoe size source</button>
<button (click)="updateLargestSize()">Set to the largest size</button>
</div>
<label for="shoeSize">
<span>Choose a shoe size: </span>
<select id="shoeSize" name="shoeSize" [(ngModel)]="currentShoeSize">
@for (size of shoeSizes(); track size) {
<option [ngValue]="size">{{ size }}</option>
}
</select>
</label>
The template displays the source of the LinkedSignal, which is an array of numbers. The currentShoeSize
LinkedSignal displays the selected element of the array. The index
computed signal derives the index of the currentShoeSize
in the source. The first button calls the changeShoeSizes
method to update the source and cause the currentShoeSize
to set or reset the value. The updateLargeSizes
method sets the currentShoeSize
LinkedSignal to the last element of the array. Finally, the template populates a dropdown to select a value to write to the currentShoeSize
LinkedSignal.
import { ChangeDetectionStrategy, Component, computed, linkedSignal, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
const SHOE_SIZES = [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10];
const SHOE_SIZES2 = [4, 5, 6, 7, 8, 9, 10, 11, 12]
@Component({
selector: 'app-shoe-sizes',
standalone: true,
imports: [FormsModule],
template: `... inline template ...`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export default class ShoeSizesComponent {
shoeSizes = signal(SHOE_SIZES);
currentShoeSize = linkedSignal<number[], number>({
source: this.shoeSizes,
computation: (options, previous) => {
if (!previous) {
return options[0];
}
return options.includes(previous.value) ? previous.value : options[0];
}
});
index = computed(() => this.shoeSizes().indexOf(this.currentShoeSize()));
changeShoeSizes() {
if (this.shoeSizes()[0] === SHOE_SIZES2[0]) {
this.shoeSizes.set(SHOE_SIZES);
} else {
this.shoeSizes.set(SHOE_SIZES2);
}
}
updateLargestSize() {
const largestSize = this.shoeSizes().at(-1);
if (typeof largestSize !== 'undefined') {
this.currentShoeSize.set(largestSize);
}
}
}
The significant part of this demo is the reset of the currentShoeSize
after invoking the changeShoeSizes
method. This method toggles the shoeSizes
signal between [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10] and [4, 5, 6, 7, 8, 9, 10, 11, 12] and updates the source. Then, the currentShoeSizes
LinkedSignal uses the computation to calculate the new value.
computation: (options, previous) => {
if (!previous) {
return options[0];
}
return options.includes(previous.value) ? previous.value : options[0];
}
If the previous
Object is undefined, the first array element is returned. When the previous value exists in the new array, the computation function returns it. Otherwise, the function returns the first array element.
For example, the source is [4, 5, 6, 7, 8, 9, 10, 11, 12], and the currentShoeSize
is 10. When the new source becomes [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9,5, 10], the computation function does not reset the value because the array also has 10. If the currentShoeSize
is 12, it is not found in the array, and the function resets the value to 5, which is the first element of the array. If I change the currentShoeSize
to 6.5 and update the source to [4,5,….,12], the function resets the value to 4, the first array element.
Demo 4: Encapsulate the LinkedSignal in a store
// shoe-sizes.store.ts
import { linkedSignal, signal } from '@angular/core';
const SHOE_SIZES = [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10];
const SHOE_SIZES2 = [4, 5, 6, 7, 8, 9, 10, 11, 12];
const _shoeSizes = signal(SHOE_SIZES);
const _currentShoeSize = linkedSignal<number[], number>({
source: _shoeSizes,
computation: (options, previous) => {
if (!previous) {
// reset to the first size
return options[0];
}
// shoe size found in the new shoe size list
// reset to the first shoe size because the new list does not contain the previous shoe size
return options.includes(previous.value) ? previous.value : options[0];
}
});
export const ShoeSizesStore = {
shoeSizes: _shoeSizes.asReadonly(),
currentShoeSize: _currentShoeSize.asReadonly(),
updateShoeSize(value: number) {
_currentShoeSize.set(value);
},
changeShoeSizes() {
if (_shoeSizes()[0] === SHOE_SIZES2[0]) {
_shoeSizes.set(SHOE_SIZES);
} else {
_shoeSizes.set(SHOE_SIZES2);
}
},
updateLargestSize() {
const largestSize = _shoeSizes().at(-1);
if (typeof largestSize !== 'undefined') {
this.updateShoeSize(largestSize);
}
}
}
// shoe-sizes-store.component.ts
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ShoeSizesStore } from '../stores';
@Component({
selector: 'app-shoe-sizes-store',
standalone: true,
imports: [FormsModule],
template: `
<h2>Reset linked signal after updating source</h2>
<p>Source: {{ shoeSizes() }}</p>
<p>Shoe size: {{ currentShoeSize() }}</p>
<p>Shoe index: {{ index() }}</p>
<div>
<button (click)="changeShoeSizes()">Update shoe size source</button>
<button (click)="updateLargestSize()">Set to the largest size</button>
</div>
<label for="shoeSize">
<span>Choose a shoe size: </span>
<select id="shoeSize" name="shoeSize" [ngModel]="currentShoeSize()" (ngModelChange)="updateShoeSize($event)">
@for (size of shoeSizes(); track size) {
<option [ngValue]="size">{{ size }}</option>
}
</select>
</label>
`,
styles: ``,
changeDetection: ChangeDetectionStrategy.OnPush
})
export default class ShoeSizesStoreComponent {
currentShoeSize = ShoeSizesStore.currentShoeSize;
shoeSizes = ShoeSizesStore.shoeSizes;
index = computed(() => this.shoeSizes().indexOf(this.currentShoeSize()));
constructor() {
this.updateShoeSize(5);
}
updateShoeSize(value: number) {
ShoeSizesStore.updateShoeSize(value);
}
changeShoeSizes = ShoeSizesStore.changeShoeSizes;
updateLargestSize = ShoeSizesStore.updateLargestSize;
}
I move the component’s LinkedSignal logic to a store. The component’s constructor sets the value of the LinkedSignal to 5. The other modification is to decompose double binding ngModel to ngModel input and ngModelChange event emitter. This is because the currentShoeSize
is read-only, and I must invoke the updateShoeSize
method to update the #currentShoeSize
LinkedSignal in the store.
Conclusions:
- LinkedSignal has a source that triggers the computation function to set or reset value.
- The computation function accepts the source and the previous object. It can use both parameters to execute logic to return the next value.
- LinkedSignal is a WritableSignal that can set and update the value and return a read-only signal.
- LinkedSignal can have a different value than the source because developers can directly write values for it.