---
title: "Combine Async Pipes in Angular: From combineLatest to Signals and @let"
date: "2022-03-20"
slug: "combine-async-pipes-in-angular-how-to-avoid-multiple-pipes"
author: "Dany Paredes"
canonical: "https://danywalls.com/combine-async-pipes-in-angular-how-to-avoid-multiple-pipes"
description: "Learn how to avoid multiple async pipes and duplicate subscriptions in Angular templates using combineLatest, modern Signals (toSignal), and the @let template syntax."
---


> ⚡ **Using Modern Angular (v17+)?** Jump directly to [2. The Modern Approach: Converting Streams with toSignal()](#2-the-modern-approach-converting-streams-with-tosignal-) to eliminate the `async` pipe completely.

If you are building an Angular application that fetches data from multiple APIs, you've probably written a template that looks like this:

```html
<!-- ⚠️ 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:

```typescript
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:

```html
@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:

```typescript
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()`). No `async` pipe 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:

```html
@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-container` wrappers in the DOM just to unwrap an observable.

---

## Summary

To keep your Angular applications fast and clean:

1. **Never repeat `async` pipes** on the same observable across template elements. It causes duplicate network requests.
2. **Use `combineLatest`** to assemble a unified `vm$` ViewModel for complex RxJS streams.
3. **Embrace `toSignal()`** in modern components to benefit from synchronous template reads and signal reactivity.
4. **Use `@let`** in Angular 18+ to simplify template variable scoping without nesting `ng-container`.

For more modern Angular architecture techniques, check out my guides on [Essential Angular Interview Questions](/essential-angular-questions-for-junior-and-mid-level-job-interviews) and [Sharing Data Between Components](/how-to-share-data-between-components-in-angular)!

