How to Leverage Signals in Angular Services for Better Data Communication

Connie Leung - Jun 18 '23 - - Dev Community

Introduction

In this blog post, I would like to convert "Service with a Subject" to "Service with a Signal " and expose signals only. It is made possible by calling toSignal to convert Observable to signal. Then, I can pass signal values to Angular components to display data. After using signal values directly in the application, inline templates don't have to use async pipe to resolve Observable. Moreover, imports array of the components do not need to NgIf and AsyncPipe.

Source codes of "Service with a Subject"

// pokemon.service.ts

import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class PokemonService {
  private readonly pokemonIdSub = new Subject<number>();
  readonly pokemonId$ = this.pokemonIdSub.asObservable();

  updatePokemonId(pokemonId: number) {
    this.pokemonIdSub.next(pokemonId);
  }
}
Enter fullscreen mode Exit fullscreen mode
// pokemon.http.ts

export const retrievePokemonFn = () => {
  const httpClient = inject(HttpClient);
  return (id: number) => httpClient.get<Pokemon>(`https://pokeapi.co/api/v2/pokemon/${id}`)
    .pipe(
      map((pokemon) => ({
        id: pokemon.id,
        name: pokemon.name,
        height: pokemon.height,
        weight: pokemon.weight,
        back_shiny: pokemon.sprites.back_shiny,
        front_shiny: pokemon.sprites.front_shiny,
        abilities: pokemon.abilities.map((ability) => ({
          name: ability.ability.name,
          is_hidden: ability.is_hidden
        })),
        stats: pokemon.stats.map((stat) => ({
          name: stat.stat.name,
          effort: stat.effort,
          base_stat: stat.base_stat,
        })),
      }))
    );
}

export const getPokemonId = () => inject(PokemonService).pokemonId$;
Enter fullscreen mode Exit fullscreen mode
// pokemon.component.ts

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [AsyncPipe, NgIf, PokemonControlsComponent, PokemonAbilitiesComponent, PokemonStatsComponent, PokemonPersonalComponent],
  template: `
    <h1>
      Display the first 100 pokemon images
    </h1>
    <div>
      <ng-container *ngIf="pokemon$ | async as pokemon">
        <div class="container">
          <img [src]="pokemon.front_shiny" />
          <img [src]="pokemon.back_shiny" />
        </div>
        <app-pokemon-personal [pokemon]="pokemon"></app-pokemon-personal>
        <app-pokemon-stats [stats]="pokemon.stats"></app-pokemon-stats>
        <app-pokemon-abilities [abilities]="pokemon.abilities"></app-pokemon-abilities>
      </ng-container>
    </div>
    <app-pokemon-controls></app-pokemon-controls>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  retrievePokemon = retrievePokemonFn();
  pokemon$ = getPokemonId().pipe(switchMap((id) => this.retrievePokemon(id)));
}
Enter fullscreen mode Exit fullscreen mode

PokemonService encapsulates pokemonIdSub subject and exposes pokemonId$ Observable. In PokemonComponent, I invoke retrievePokemon function to retrieve a new Pokemon whenever pokemonId$ emits a new id. pokemon$ is a Pokemon Observable that I resolve in the inline template in order to assign the Pokemon object to child components.

Next, I am going to convert PokemonService from "Service with a Subject" to "Service with a Signal" to highlight the benefits of using signals.

Conversion to "Service with a Signal"

First, I combine pokemon.http.ts and pokemon.service.ts to move retrievePokemonFn to the service.

// pokemon.service.ts

// Point 1: move helper functions to this service
const retrievePokemonFn = () => {
  const httpClient = inject(HttpClient);
  return (id: number) => httpClient.get<Pokemon>(`https://pokeapi.co/api/v2/pokemon/${id}`);
}

const pokemonTransformer = (pokemon: Pokemon): DisplayPokemon => {
  const stats = pokemon.stats.map((stat) => ({
    name: stat.stat.name,
    effort: stat.effort,
    baseStat: stat.base_stat,
  }));

  const abilities = pokemon.abilities.map((ability) => ({
    name: ability.ability.name,
    isHidden: ability.is_hidden
  }));

  const { id, name, height, weight, sprites } = pokemon;

  return {
    id,
    name,
    height,
    weight,
    backShiny: sprites.back_shiny,
    frontShiny: sprites.front_shiny,
    abilities,
    stats,
  }
}

@Injectable({
  providedIn: 'root'
})
export class PokemonService {
  private readonly pokemonId = signal(1);
  private readonly retrievePokemon = retrievePokemonFn()

  pokemon$ = toObservable(this.pokemonId).pipe(
    switchMap((id) => this.retrievePokemon(id)),
    map((pokemon) => pokemonTransformer(pokemon)),
  );

  updatePokemonId(input: PokemonDelta | number) {
    if (typeof input === 'number') {
      this.pokemonId.set(input);
    } else {
      this.pokemonId.update((value) => {
        const newId = value + input.delta;
        return Math.min(input.max, Math.max(input.min, newId));
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

pokemonId is a signal that stores Pokemon id. When toObservable(this.pokemonId) emits an id, the Observable invokes this.retrievePokemon to retrieve a Pokemon and pokemonTransformer to transform the data. .

Next, I am going to modify components to use signals instead of Observable.

Modify Pokemon Component to use signals

const initialValue: DisplayPokemon = {
  id: -1,
  name: '',
  height: 0,
  weight: 0,
  backShiny: '',
  frontShiny: '',
  abilities: [],
  stats: [],
}

export class PokemonComponent {
  service = inject(PokemonService);

  // Point 2:  convert Observable to signal using toSignal
  pokemon = toSignal(this.pokemonService.pokemon$, { initialValue });

  // Point 3: compute a signal from an existing signal
  personalData = computed(() => {
    const { id, name, height, weight } = this.pokemon();
    return [
      { text: 'Id: ', value: id },
      { text: 'Name: ', value: name },
      { text: 'Height: ', value: height },
      { text: 'Weight: ', value: weight },
    ];
  });
}
Enter fullscreen mode Exit fullscreen mode

PokemonComponent injects PokemonService to access pokemon$ Observable. Then, toSignal converts the Pokemon Observable to a Pokemon signal.

personalData is a computed signal that derives from this.pokemon() signal value. It is a signal that returns the id, name, height and weight of a Pokemon

Without the pokemon$ Observable, I revise the inline template to render signal values and pass signal values to children components.

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [PokemonControlsComponent, PokemonAbilitiesComponent, PokemonStatsComponent, PokemonPersonalComponent],
  template: `
    <h2>
      Display the first 100 pokemon images
    </h2>
    <div>
      <ng-container>
        <div class="container">
          <img [src]="pokemon().frontShiny" />
          <img [src]="pokemon().backShiny" />
        </div>
        <app-pokemon-personal [personalData]="personalData()" />
        <app-pokemon-stats [stats]="pokemon().stats" />
        <app-pokemon-abilities [abilities]="pokemon().abilities" />
      </ng-container>
    </div>
    <app-pokemon-controls />
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent { ... }
Enter fullscreen mode Exit fullscreen mode

One obvious change is the inline template eliminates ngContainer, ngIf and async pipe. It also leads to the removal of AsyncPipe and NgIf from the imports array.

The inline template invokes pokemon() multiple times to access frontShiny, backShiny, stats and abilities properties. stats and abilities subsequently become the inputs of PokemonStatsComponent and PokemonAbilitiesComponent respectively.

Similarly, the result of personalData() is passed to personalData input of PokemonPersonalComponent.

Modify child components to accept signal value input

The application breaks after code changes in PokemonComponent. It is because the input of PokemonPersonalComponent has different type. In order to fix the problem, I correct the input value of the child component.

// pokemon-personal.component.ts

@Component({
  selector: 'app-pokemon-personal',
  standalone: true,
  imports: [NgTemplateOutlet, NgFor],
  template:`
    <div class="pokemon-container" style="padding: 0.5rem;">
      <ng-container *ngTemplateOutlet="details; context: { $implicit: personalData }"></ng-container>
    </div>
    <ng-template #details let-personalData>
      <label *ngFor="let data of personalData">
        <span style="font-weight: bold; color: #aaa">{{ data.text }}</span>
        <span>{{ data.value }}</span>
      </label>
    </ng-template>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonPersonalComponent {
  @Input({ required: true })
  personalData: ({ text: string; value: string; } | { text: string; value: number })[];
}
Enter fullscreen mode Exit fullscreen mode

I replace pokemon input with personalData and use the latter in the inline template to render array values.

If I use Observable in PokemonComponent, I cannot construct personalData in a reactive manner. I would subscribe Pokemon Observable and construct personaData in the callback. Furthermore, I complete the Observable using takeUntilDestroyed to prevent memory leak.

This is it and I have converted the Pokemon service from "Service with a Subject" to "Service with a Signal". The Pokemon service encapsulates HTTP call, converts Observable to signal and exposes signals to outside. In components, I call signal functions within inline templates to display their values. Moreover, the components stop importing NgIf and AsyncPipe because they don't need to resolve Observable.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .