DEV Community

Dominik Paszek
Dominik Paszek

Posted on

Stop Writing .subscribe() in Angular Components — Use toSignal Instead

If you've been writing Angular for more than a week, you know this pattern by heart:

protected userData = signal<UserData | undefined>(undefined);

ngOnInit() {
  this.userService.getUserData()
    .pipe(
      tap(data => this.userData.set(data)),
      takeUntilDestroyed(this.destroyRef)
    )
    .subscribe();
}
Enter fullscreen mode Exit fullscreen mode

Signal. Subscribe. tap. set. Cleanup. Every. Single. Time.

It works, but there's a lot of moving parts for something that boils down to "I want to show this data in my template." Turns out Angular has a one-liner for exactly this — toSignal.


The core idea

Observables and Signals live in different worlds. Observables are async — they emit values over time, can be cold or hot, and can stay silent for a while. Signals are synchronous — they always have a current value and work directly in templates without any pipe magic.

toSignal is the bridge. You hand it an Observable, it gives you back a Signal that:

  • subscribes automatically
  • updates on every emission
  • cleans itself up when the component is destroyed

Zero .subscribe() calls in your component.


Basic usage

import { toSignal } from '@angular/core/rxjs-interop';

@Component({ ... })
export class UserComponent {
  private readonly userService = inject(UserService);

  protected userData = toSignal(this.userService.getUserData());
}
Enter fullscreen mode Exit fullscreen mode

That's it. No ngOnInit, no DestroyRef, no takeUntilDestroyed. The type is inferred automatically — because getUserData() returns Observable<UserData>, you get Signal<UserData | undefined> back.

In the template:

@if (userData(); as user) {
  <h2>{{ user.name }}</h2>
} @else {
  <p>Loading...</p>
}
Enter fullscreen mode Exit fullscreen mode

No async pipe. Just call it like a function.

One rule to remember: toSignal must be called inside an injection context — a constructor, a field initializer, or a function called during construction. Same rules as inject().


Why | undefined?

There's a gap between when your component loads and when the Observable actually emits. Observables can be silent — Signals cannot. Before the first emission, the Signal needs something to hold. That something is undefined by default, hence Signal<T | undefined>.

You have two options to get rid of it.

Option 1: initialValue

Use this when you have a sensible empty state — an empty array, an empty string, a default object.

protected posts = toSignal(
  this.userService.getUserPosts(1),
  { initialValue: [] as Post[] }
);
// Type: Signal<Post[]> — no undefined
Enter fullscreen mode Exit fullscreen mode

Option 2: requireSync

Use this when your Observable is guaranteed to emit synchronously — like a BehaviorSubject.

protected liveUser = toSignal(
  this.userService.getUserDataHot(),
  { requireSync: true }
);
// Type: Signal<UserData> — TypeScript knows it's never undefined
Enter fullscreen mode Exit fullscreen mode

If you get this wrong and the Observable doesn't emit synchronously, Angular tells you immediately:

Error: NG0601: toSignal() was called with requireSync,
but the Observable did not emit synchronously.
Enter fullscreen mode Exit fullscreen mode

No silent bugs. Fast feedback.


What about multiple signals from the same source?

This is where it gets slightly more interesting. If you need two signals derived from the same Observable, naively doing this creates two separate subscriptions:

protected userData = toSignal(this.userService.getUserData());
protected posts = toSignal(
  this.userService.getUserData().pipe(
    switchMap(data => this.userService.getUserPosts(data.id))
  )
);
Enter fullscreen mode Exit fullscreen mode

The fix is shareReplay(1) — share the upstream, and replay the last value to any late subscribers:

private readonly userData$ = this.userService.getUserData().pipe(shareReplay(1));

protected userData = toSignal(this.userData$);
protected posts = toSignal(
  this.userData$.pipe(
    switchMap(data => this.userService.getUserPosts(data.id))
  ),
  { initialValue: [] as Post[] }
);
Enter fullscreen mode Exit fullscreen mode

shareReplay(1) does two things: it multicasts (one HTTP request, multiple consumers) and it replays the last value to subscribers who come in slightly later — which matters because toSignal subscribes at slightly different times internally.


Auto-cleanup — same engine as takeUntilDestroyed

If you've seen the takeUntilDestroyed pattern, the cleanup story here is identical. toSignal internally grabs DestroyRef from the injection context and unsubscribes when the component is destroyed. You don't have to think about it.

That's also why the injection context requirement exists — without it, toSignal has no way to know when to clean up.


Quick reference

// Default — use when Observable is async and you have no meaningful fallback
toSignal(obs$)
// → Signal<T | undefined>

// initialValue — use when you have a sensible empty state
toSignal(obs$, { initialValue: [] as T[] })
// → Signal<T>

// requireSync — use with BehaviorSubject or any synchronous Observable
toSignal(obs$, { requireSync: true })
// → Signal<T>
Enter fullscreen mode Exit fullscreen mode

Before / After

Before:

protected userData = signal<UserData | undefined>(undefined);
private readonly destroyRef = inject(DestroyRef);

ngOnInit() {
  this.userService.getUserData()
    .pipe(
      tap(data => this.userData.set(data)),
      switchMap(data => this.userService.getUserPosts(data.id)),
      takeUntilDestroyed(this.destroyRef)
    )
    .subscribe(posts => this.posts.set(posts));
}
Enter fullscreen mode Exit fullscreen mode

After:

private readonly userData$ = this.userService.getUserData().pipe(shareReplay(1));

protected userData = toSignal(this.userData$);
protected posts = toSignal(
  this.userData$.pipe(switchMap(d => this.userService.getUserPosts(d.id))),
  { initialValue: [] as Post[] }
);
Enter fullscreen mode Exit fullscreen mode

The component no longer knows that Observables even exist. It just has Signals.


This is part 2 of a series on modern Angular patterns. Part 1 covered takeUntilDestroyed and Hot vs Cold Observables. Next up: toObservable — going the other direction, from Signal back to Observable.

Top comments (0)