How to compute Angular signal from Observable?

773 Views Asked by At

I have started to slowly embrace the new Angular's Signal in my projects. I stumbled upon the following scenario, and while it works, I wonder if this is the "Angular way" of doing it.

In my component, I have a signal which lacks of an initial value, so I initialise it like this:

private readonly _id: Signal<string | undefined> = signal(undefined);

Only on ngOnInit I'm really able to set the id (e.g. this._id.set('valid-id')); The thing is that I need the id in order to perform an Http request. In an Observable world, I'd go in this direction:

public readonly result$ = toObservable(this._id).pipe(
    filter(Boolean),
    switchMap((id) => this.myService.getById(id))
);

As a novice in the Signals world, I'd go like this:

public readonly result = toSignal(
    toObservable(this._id).pipe(
        filter(Boolean),
        switchMap((id) => this.myService.getById(id))
    ),
    { initialValue: [] }
);

But I'm afraid that maybe I'm a little biased towards my love for RxJS and Observables :)
So I wonder if there's an Angular way to do this instead? Perhaps I have to use compute() or effect()? Calling .subscribe() is definitely not an option!

2

There are 2 best solutions below

4
maxime1992 On BEST ANSWER

Signals aren't replacing observables. They're good to simplify computed, synchronous data.

As soon as it comes to async data, observables is the way to go.

Signals are new and help to make cascading synchronous updates, which is nice and simple. But it solves a completely different problem than RxJS who's focus is to solve async data flows.

So despite the new trend of trying to use signals everywhere, I'd argue that different tools are for different usages.

As an example, here's a very recent tweet I've seen:

enter image description here

This look very nice, simple and shiny. But it also introduces major race conditions that'll at best make your app look buggy and at worst make your app misbehave and potentially trigger side effects when they shouldn't or with a wrong value.

See my 2 responses where I explain a bit why here and here, but the race condition is gone with RxJS using one operator (switchMap, concatMap, exhaustMap, mergeMap based on what you want). So again, use the right tool for the job.

One could wonder what's the official recommended way. While it's not explicitly mentioned to use observables in the documentation for async data flow, you can see here that it is what they do and demo: https://angular.io/guide/rxjs-interop#toobservable

If you really wish to use a Signal instead of an Observable in your case while preserving a reactive flow that'll guarantee that you call getById whenever the _id changes, you should do exactly what you've done with toObservable and toSignal. But it's bothering you for a good reason: There's no real point in doing that here. Embrace Observables for async data flow if you don't want to have race conditions, and keep full control over how things happen and when. So in your case, unless you've shared a minimal example of what your code is doing and you really need this value to be transformed into a Signal so that it can be used with other signals, just use an observable.

Observables are complex when one has been thinking imperatively forever (which was my case a few years back). But they're not here for fun or make people who use them cool. They're here to give you the opportunity to manage more or less complex async data flows, which signals won't. The learning curve may be steep but the reward is worth it.

2
NgDaddy On

Kari, try this out. You might need effect, I guess.

import { JsonPipe } from '@angular/common';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import {
  Component,
  DestroyRef,
  OnInit,
  effect,
  inject,
  signal,
  untracked,
} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import 'zone.js';
import { filter, pipe, switchMap, tap, timer } from 'rxjs';
import { rxMethod } from '@ngrx/signals/rxjs-interop';

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <h1>Hello from {{ name }}!</h1>
    <p>id: {{ id() }}</p>
    <p>data:</p>
    <pre>{{ data() | json }}<pre>
  `,
  imports: [JsonPipe],
})
export class App implements OnInit {
  name = 'Angular';
  data = signal<any>(void 0);
  id = signal<number | undefined>(void 0);
  url = (id: number) => `https://jsonplaceholder.typicode.com/todos/${id}`;
  http = inject(HttpClient);
  destroyRef = inject(DestroyRef);

  fetchData = rxMethod<number | undefined>(
    pipe(
      filter(Boolean),
      tap((value) => console.log(`piped ${value}`)),
      switchMap((id) => this.http.get(this.url(id))),
      tap((value) => console.log(`piped 2 ${value}`)),
      tap(this.data.set)
    )
  );

  constructor() {
    /**
     * rxMethod
     */
    this.fetchData(this.id);
    /**
     * an alternative with `effect`
     */
    // effect(() => {
    //   const id = this.id();
    //   if (!id) {
    //     return;
    //   }
    //   untracked(() =>
    //     this.http
    //       .get(this.url(id))
    //       .pipe(tap(this.data.set), takeUntilDestroyed(this.destroyRef))
    //       .subscribe()
    //   );
    // });
  }

  ngOnInit() {
    this.id.set(1);
    timer(2000)
      .pipe(
        tap(() => this.id.set(2)),
        takeUntilDestroyed(this.destroyRef)
      )
      .subscribe();
  }
}

bootstrapApplication(App, { providers: [provideHttpClient()] });

Source code @ stackblitz