DEV Community

Connie Leung
Connie Leung

Posted on • Edited on

11

How to Leverage Signals in Angular Services for Better Data Communication

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:

Neon image

Build better on Postgres with AI-Assisted Development Practices

Compare top AI coding tools like Cursor and Windsurf with Neon's database integration. Generate synthetic data and manage databases with natural language.

Read more →

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.

Image of Stellar post

Check out Episode 1: How a Hackathon Project Became a Web3 Startup 🚀

Ever wondered what it takes to build a web3 startup from scratch? In the Stellar Dev Diaries series, we follow the journey of a team of developers building on the Stellar Network as they go from hackathon win to getting funded and launching on mainnet.

Read more

👋 Kindness is contagious

Dive into this informative piece, backed by our vibrant DEV Community

Whether you’re a novice or a pro, your perspective enriches our collective insight.

A simple “thank you” can lift someone’s spirits—share your gratitude in the comments!

On DEV, the power of shared knowledge paves a smoother path and tightens our community ties. Found value here? A quick thanks to the author makes a big impact.

Okay