Service with a Signal in Angular

Share this post:




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. Second, I declare pokemon that stores the result of toSignal that is a signal. Third, I can use pokemon signal to compute personalData signal and assign personalData to PokemonPersonalComponent.

// 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,
  }
}

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

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

  // Point 2:  convert Observable to signal using toSignal
  pokemon = toSignal(this.pokemonIdSub.pipe(
    switchMap((id) => this.retrievePokemon(id)),
    map((pokemon) => pokemonTransformer(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 },
    ];
  });

  updatePokemonId(input: PokemonDelta | number) {
    if (typeof input === 'number') {
      this.pokemonIdSub.next(input);
    } else {
      const newId = this.pokemonIdSub.getValue() + input.delta;
      const nextPokemonId = Math.min(input.max, Math.max(input.min, newId));
      this.pokemonIdSub.next(nextPokemonId);
    }
  }
}
Enter fullscreen mode

Exit fullscreen mode

pokemonIdSub is a BehaviorSubject that stores Pokemon id. When the BehaviorSubject emits an id, the Observable invokes this.retrievePokemon to retrieve a Pokemon and pokemonTransformer to transform the data. 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.

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



Modify Pokemon Component to use signals

export class PokemonComponent {
  service = inject(PokemonService);
  pokemon = this.service.pokemon;
  personalData = this.service.personalData;
}
Enter fullscreen mode

Exit fullscreen mode

PokemonComponent injects PokemonService to access pokemon and personalData signals. Without the pokemon$ Observable, I revise the inline template to render signal value and pass signal values to child 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-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 { ... }
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:



Source link