Combine Async Pipes in Angular: From combineLatest to Signals and @let

⚡ Using Modern Angular (v17+)? Jump directly to 2. The Modern Approach: Converting Streams with toSignal() to eliminate the
asyncpipe completely.
If you are building an Angular application that fetches data from multiple APIs, you've probably written a template that looks like this:
<!-- ⚠️ The Problem: Multiple async pipes causing duplicate requests -->
<div *ngIf="user$ | async as user">
<h2>{{ (user$ | async)?.name }}</h2>
<div *ngIf="stats$ | async as stats">
<p>Points: {{ (stats$ | async)?.points }}</p>
</div>
</div>This is a common and expensive mistake. If user$ is an HTTP request, using | async multiple times triggers multiple identical network requests. Even worse, nesting *ngIf structural directives creates deep, unreadable HTML.
Here is a quick overview of how we will solve this using modern Angular patterns:
| Pattern | Best Suited For | Async Pipe Needed? | Network Requests |
|---|---|---|---|
combineLatest (ViewModel) | Complex RxJS data pipelines | Yes (Just 1 at the root) | 1 |
Signals (toSignal) | Modern Zoneless Angular apps | No (Direct value read) | 1 |
@let Syntax (Angular 18+) | Quick template-level variable aliasing | Yes | 1 |
Let's examine the three cleanest ways to combine streams and eliminate duplicate subscriptions.
1. The Classic RxJS Pattern: combineLatest ViewModel 🔄
If your project is built heavily around RxJS streams, the most robust pattern is to combine all independent observables into a single vm$ (ViewModel) stream in your component class:
import { Component, inject } from '@angular/core';
import { combineLatest, Observable } from 'rxjs';
import { PlayerService, Player, PlayerStats } from './player.service';
interface PlayerViewModel {
player: Player;
stats: PlayerStats;
}
@Component({
selector: 'app-player-profile',
templateUrl: './player-profile.component.html'
})
export class PlayerProfileComponent {
private playerService = inject(PlayerService);
private playerId = 23;
player$: Observable<Player> = this.playerService.getPlayer(this.playerId);
stats$: Observable<PlayerStats> = this.playerService.getStats(this.playerId);
// Combine multiple streams into a single ViewModel observable
vm$: Observable<PlayerViewModel> = combineLatest({
player: this.player$,
stats: this.stats$
});
}In the Template (Modern Control Flow)
With modern Angular @if, you only need a single async pipe at the root of the view. Once unwrapped, you access all properties synchronously:
@if (vm$ | async; as vm) {
<div class="player-card">
<h2>{{ vm.player.name }}</h2>
<p>Position: {{ vm.player.position }}</p>
<ul>
<li>Points: {{ vm.stats.points }}</li>
<li>Rebounds: {{ vm.stats.rebounds }}</li>
</ul>
</div>
} @else {
<div class="skeleton-loader">Loading player profile...</div>
}Now there is exactly one subscription and zero duplicate network requests.
Next, let's look at how modern Angular Signals make this even simpler.
2. The Modern Approach: Converting Streams with toSignal() ⚡
With Angular Signals, you can eliminate the async pipe entirely. The @angular/core/rxjs-interop package provides toSignal(), which subscribes to an Observable under the hood and exposes it as a synchronous, reactive Signal:
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { PlayerService } from './player.service';
@Component({
selector: 'app-player-signals',
standalone: true,
template: `
@if (player(); as p) {
<div class="player-card">
<h2>{{ p.name }}</h2>
@if (stats(); as s) {
<p>Points: {{ s.points }} | Rebounds: {{ s.rebounds }}</p>
}
</div>
} @else {
<p>Loading player...</p>
}
`
})
export class PlayerSignalsComponent {
private playerService = inject(PlayerService);
private playerId = 23;
// Automatically manages subscription & unsubscription lifecycle
player = toSignal(this.playerService.getPlayer(this.playerId));
stats = toSignal(this.playerService.getStats(this.playerId));
}Why toSignal() is the recommended standard:
- No manual unsubscription: Subscriptions are tied directly to the component lifecycle and cleaned up automatically.
- Synchronous template reads: Read values with standard function calls (
player()). Noasyncpipe needed. - Fine-grained reactivity: Updates trigger only the necessary DOM bindings, setting up your app for Zoneless change detection.
Now let's examine the newest addition in Angular 18.1: the @let declaration.
3. The Angular 18.1+ Solution: @let Template Variables 🎯
Starting in Angular 18.1, you can declare local template variables directly within the template using @let. This eliminates the need for dummy structural wrapper elements just to alias an async pipe:
@let player = player$ | async;
@let stats = stats$ | async;
@if (player && stats) {
<div class="player-container">
<h2>{{ player.name }}</h2>
<p>Points: {{ stats.points }}</p>
<p>Assists: {{ stats.assists }}</p>
</div>
}Key benefits of @let:
- Works anywhere in the template scope.
- Provides strict type inference for downstream expressions.
- Avoids creating extra
ng-containerwrappers in the DOM just to unwrap an observable.
Summary
To keep your Angular applications fast and clean:
- Never repeat
asyncpipes on the same observable across template elements. It causes duplicate network requests. - Use
combineLatestto assemble a unifiedvm$ViewModel for complex RxJS streams. - Embrace
toSignal()in modern components to benefit from synchronous template reads and signal reactivity. - Use
@letin Angular 18+ to simplify template variable scoping without nestingng-container.
For more modern Angular architecture techniques, check out my guides on Essential Angular Interview Questions and Sharing Data Between Components!
These are my experiences learning and facing my daily challenges working with Angular.
Frequently Asked Questions
Why is using multiple async pipes for the same observable bad in Angular?
Every async pipe placed on an observable creates a separate subscription. If the observable is an HTTP request or uncached computation, multiple async pipes cause duplicate network requests and unnecessary change detection cycles.
How do Angular Signals (toSignal) replace the async pipe?
The toSignal() function from @angular/core/rxjs-interop converts an RxJS observable into a Signal. In the template, you read the value synchronously as a function call data(), completely removing the async pipe.
What is the @let template syntax in Angular 18.1+?
The @let syntax allows you to declare local template variables (@let user = user$ | async) without wrapping your HTML in unnecessary container elements or dummy structural directives.
How do you combine multiple observables into a single async pipe in legacy Angular?
You can use the RxJS combineLatest operator in the component class to group multiple streams into a single vm$ (ViewModel) observable, unwrapping it once in the template with *ngIf='vm$ | async as vm'.
Related Articles
Essential Angular Interview Questions for Junior and Mid-Level Developers
A practical guide to modern Angular interview questions for junior and mid-level roles, covering Signals, Standalone APIs, Control Flow, Vitest, and Zoneless Angular.
Understanding Composition vs. Inheritance in Angular
Why class inheritance often leads to constructor hell in Angular, and how to use the Directive Composition API (hostDirectives), inject(), and Signals for clean code.
How to Handle and Catch Errors in RxJS and Angular
Learn how to catch, handle, and recover from errors in RxJS streams and Angular using catchError, throwError, EMPTY, and modern Angular Signals interop.
How to Share Data Between Components in Angular (Signals & Services)
Learn the best patterns to share data between Angular components. Compare legacy Input/Output decorators and BehaviorSubjects with modern Signals, input(), output(), model(), and Signal-based services.
Share this article
If you found this guide helpful, consider sharing it with your team or fellow developers.
Real Software. Real Lessons.
I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.
Join 13,800+ developers and readers.
No spam ever. Unsubscribe at any time.