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();
}
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());
}
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>
}
No async pipe. Just call it like a function.
One rule to remember:
toSignalmust be called inside an injection context — a constructor, a field initializer, or a function called during construction. Same rules asinject().
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
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
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.
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))
)
);
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[] }
);
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>
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));
}
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[] }
);
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)