Capítulo 12: Interoperabilidade entre Signals e RxJS (toSignal, toObservable)

Especialização em Front-End com Angular • Angular 18+ • Signals, Standalone Architecture, RxJS, NgRx e SSR


🗺️ Mapa Conceitual da Ponte de Interoperabilidade Reativa

flowchart LR
    subgraph RXJS_REALM["Mundo RxJS (Eventos Assíncronos no Tempo)"]
        HTTP["HttpClient (Cold Stream)"]
        WS["WebSocket (Hot Stream)"]
        RouteParams["ActivatedRoute.paramMap"]
    end

    subgraph INTEROP["Ponte de Interoperabilidade (@angular/core/rxjs-interop)"]
        TS["toSignal(stream$, { initialValue })"]
        TO["toObservable(meuSignal)"]
    end

    subgraph SIGNALS_REALM["Mundo Signals (Estado Síncrono de UI)"]
        Comp["Componente Standalone"]
        Template["Template HTML {{ meuSignal() }}"]
        Computed["computed(() => ...)"]
    end

    RXJS_REALM -->|"toSignal()"| INTEROP
    INTEROP --> SIGNALS_REALM
    SIGNALS_REALM -->|"toObservable()"| INTEROP
    INTEROP --> RXJS_REALM

    style RXJS_REALM fill:#ede7f6,stroke:#7e57c2,stroke-width:2px
    style INTEROP fill:#fff3e0,stroke:#ff9800,stroke-width:2px
    style SIGNALS_REALM fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px

🏛️ 1. O Casamento Perfeito: RxJS + Signals

Uma dúvida muito frequente em equipes de desenvolvimento é: “Os Signals vieram para substituir o RxJS?”. A resposta oficial do time de engenharia do Angular é NÃO.

Ambas as primitivas cumprem propósitos complementares e coexistem em harmonia no que chamamos de Arquitetura Reativa Híbrida:

  • RxJS no Backend / Serviços: Excelente para orquestração assíncrona, debounce de digitação, cancelamento de requisições, tratamento de erro, reconexão de sockets e controle de concorrência.
  • Signals no Componente / Template: Perfeito para armazenar estado síncrono, expor propriedades legíveis pela view, calcular valores derivados com computed() e acionar renderização cirúrgica sem Zone.js.

Para conectar esses dois mundos com total segurança e zero vazamento de memória, o Angular fornece o pacote @angular/core/rxjs-interop.


⚙️ 2. Convertendo Streams em Estado com toSignal()

A função toSignal() assina um Observable e transforma suas emissões em um Signal somente-leitura (Signal<T>).

2.1 Gerenciamento Automático de Memória

Quando chamado dentro de um contexto de injeção (como no construtor ou na inicialização de campos da classe), o toSignal() cancela automaticamente a subscrição quando o componente for destruído! Você nunca mais precisará escrever unsubscribe() manual para consumir dados assíncronos no template.

import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';

interface PerfilUsuario {
  id: number;
  nome: string;
  email: string;
  saldo: number;
}

@Component({
  selector: 'app-perfil-cliente',
  standalone: true,
  template: `
    <div class="card">
      @if (usuario(); as u) {
        <h2>{{ u.nome }}</h2>
        <p>Email: {{ u.email }}</p>
        <p>Saldo em Conta: {{ u.saldo | currency:'BRL' }}</p>
      } @else {
        <p>Carregando perfil do usuário...</p>
      }
    </div>
  `
})
export class PerfilClienteComponent {
  private http = inject(HttpClient);

  // Observable transformado diretamente em Signal seguro e tipado!
  usuario = toSignal(
    this.http.get<PerfilUsuario>('https://api.banco.com/perfil').pipe(
      catchError(() => of(null))
    ),
    { initialValue: null } // Valor inicial síncrono obrigatório para evitar tipo 'undefined'
  );
}

2.2 Opções Avançadas do toSignal()

  1. initialValue: Define o valor inicial do signal até que a primeira emissão do Observable ocorra.
  2. requireSync: true: Exige que o Observable emita sincronicamente na subscrição (ex: um BehaviorSubject). Se o Observable for assíncrono, lança uma exceção.
  3. rejectErrors: true: Faz com que erros na stream lancem exceções quando o signal for lido, em vez de ficarem silenciosos.

🔄 3. Convertendo Signals em Streams com toObservable()

Quando você tem um Signal (por exemplo, um termo digitado ou um filtro de categoria selecionado) e precisa passar esse valor por uma pipeline do RxJS contendo operadores como debounceTime, switchMap ou distinctUntilChanged, utiliza-se toObservable():

import { Component, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap, catchError } from 'rxjs/operators';
import { of } from 'rxjs';

@Component({
  selector: 'app-busca-reativa',
  standalone: true,
  template: `
    <input 
      type="text" 
      [value]="termo()" 
      (input)="aoDigitar($event)" 
      placeholder="Digite para pesquisar..." 
    />

    <ul>
      @for (item of resultados(); track item.id) {
        <li>{{ item.titulo }}</li>
      } @empty {
        <li>Nenhum resultado disponível.</li>
      }
    </ul>
  `
})
export class BuscaReativaComponent {
  private http = inject(HttpClient);

  // 1. Estado local em Signal
  termo = signal('');

  // 2. Transforma o Signal em Observable para aplicar operadores RxJS de tempo
  private termo$ = toObservable(this.termo);

  // 3. Orquestra a busca assíncrona com switchMap e converte o resultado de volta para Signal!
  resultados = toSignal(
    this.termo$.pipe(
      debounceTime(350),
      distinctUntilChanged(),
      switchMap(q => {
        if (!q || q.length < 2) return of([]);
        return this.http.get<any[]>(`/api/artigos?busca=${q}`).pipe(
          catchError(() => of([]))
        );
      })
    ),
    { initialValue: [] }
  );

  aoDigitar(event: Event): void {
    const input = event.target as HTMLInputElement;
    this.termo.set(input.value);
  }
}

⚖️ 4. Matriz Comparativa: toSignal vs toObservable

Critério toSignal() toObservable()
Sentido da Conversão Observable<T> ➔ Signal<T> Signal<T> ➔ Observable<T>
Momento de Atualização Imediato à emissão do Observable Agendado em microtask assíncrona
Cancelamento de Recursos Automático via DestroyRef Automático via ciclo de vida do RxJS
Casos Típicos Consumir chamadas HTTP e Stores no Template Enviar signals para pipelines de debounce e busca

⚠️ 5. Armadilhas Comuns e Anti-Patterns

  1. Atraso de Microtask no toObservable():
    • O toObservable() não emite sincronicamente quando o signal é alterado; ele aguarda uma microtask do JavaScript para consolidar mudanças em lote (batching). Portanto, se você alterar um signal e imediatamente tentar ler a stream de forma síncrona, a nova emissão ainda não terá ocorrido.
  2. Chamar toSignal() sem Valor Inicial:
    • Sem initialValue, o tipo inferido do signal será Signal<T | undefined>. Isso força você a lidar com undefined em todo o template ou código TypeScript.

🛡️ 6. Boas Práticas e Arquitetura de Produção

  • Evite o AsyncPipe em Novos Códigos: Sempre que receber um Observable de um serviço, converta-o para signal com toSignal() no componente. Isso simplifica a leitura no template (apenas meuDado()), melhora a interoperabilidade com computed() e prepara seu projeto para o modo Zoneless.
  • Mantenha os Serviços Assíncronos em RxJS: Não force seus serviços a retornarem apenas Signals se envolverem streams contínuas ou canceláveis. Deixe a conversão para Signals na fronteira de consumo (no componente ou fachada).

📋 7. Cheat-Sheet: Resumo Executivo

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

// Observable ➔ Signal
const dados = toSignal(http.get('/api'), { initialValue: [] });

// Signal ➔ Observable
const termo$ = toObservable(this.termoSignal);