DEV Community

Connie Leung
Connie Leung

Posted on • Updated on • Originally published at blueskyconnie.com

Reset or set the value in LinkedSignal in Angular 19

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 a rewrite of the third demo, in which the store 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

<div>
      <button (click)="pageNumber.set(1)">First</button>
      <button (click)="changePageNumber(-1)">Prev</button>    
      <button (click)="changePageNumber(1)">Next</button>
      <button (click)="pageNumber.set(200)">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>
Enter fullscreen mode Exit fullscreen mode

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 percentageOfCompletion computed signal.

// pagination.component.ts

import { ChangeDetectionStrategy, Component, computed, linkedSignal, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-pagination',
  standalone: true,
  imports: [FormsModule],
  templateUrl: 
  template: `...the inline template…`
  changeDetection: ChangeDetectionStrategy.OnPush
})
export default class PaginationComponent {
   pageNumber = signal(1)

  currentPageNumber = linkedSignal<number, number>({ 
    source: this.pageNumber,
    computation: (pageNumber, previous) => {
      if (!previous) {
        return pageNumber;
      }

      return (pageNumber < 1 || pageNumber > 200) ? previous.value : pageNumber
    }
  });

  percentageOfCompletion = computed(() => `${((this.currentPageNumber() * 1.0) * 100 / 200).toFixed(2)}%`);

  changePageNumber(offset: number) {
    this.pageNumber.update((value) => Math.max(1, Math.min(200, value + offset)));
  }
}
Enter fullscreen mode Exit fullscreen mode

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. The linked signal returns the page number when the' previous' object is undefined. 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>
Enter fullscreen mode Exit fullscreen mode

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, the favoriteCountry is reset. The other button calls the changeCountry function to directly write to the country signal and the favoriteCountry LinkedSignal. 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…`,
  styles: ``,
  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() {
    this.country.set('Canada');
    this.favoriteCountry.update((c) => c.toUpperCase());
  }
}
Enter fullscreen mode Exit fullscreen mode
favoriteCountry = linkedSignal(() => this.country());
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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); 
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 in the process. 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]; 
}
Enter fullscreen mode Exit fullscreen mode

If the previous Object is undefined, the first array element is returned. The function returns the previous value if it exists in the new array. 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. If the new source becomes [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9,5, 10], the function does not reset the value because 10 is found. 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];        
    }

    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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// shoe-sizes-store.component.ts

import { ShoeSizesStore } from '../stores';

@Component({
  selector: 'app-shoe-sizes-store',
  standalone: true,
  imports: [FormsModule],
  template: `
    <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>
  `,
})
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;
}
Enter fullscreen mode Exit fullscreen mode

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.

References:

Top comments (0)