Este artigo explica a diferença entre Angular Signals e RxJS, quando usar um, quando usar o outro e como combiná-los em aplicações Angular reais.
Angular Signals e RxJS não são duas ferramentas em guerra. São duas ferramentas diferentes, criadas para resolver problemas diferentes, e nas aplicações Angular modernas trabalham muitas vezes muito bem em conjunto.
O problema surge quando os comparamos como se fossem cem por cento intercambiáveis. Na realidade, os Signals são perfeitos para representar um estado atual, legível e derivável de forma síncrona. O RxJS, pelo contrário, é perfeito para modelar fluxos assíncronos no tempo: eventos, cancelamentos, debounce, retry, WebSockets e combinações complexas de streams.
Por isso, a pergunta certa não é: “Signals ou RxJS?”. A pergunta certa é: “Estou a gerir um estado atual ou um fluxo de eventos no tempo?”.
O modelo mental: valor atual vs fluxo no tempo
Um Signal é como uma variável reativa. Contém um valor atual. Quando esse valor muda, o Angular sabe que partes da interface têm de ser atualizadas.
Um Observable RxJS é uma sequência de valores no tempo. Pode emitir zero, um ou muitos valores. Pode terminar, pode gerar erro, pode ser cancelado através de unsubscribe e pode ser transformado com operadores como map, filter, switchMap, debounceTime, retry e muitos outros.
| Cenário | Signals | RxJS |
|---|---|---|
| Estado local da UI | Ótimo | Possível, mas muitas vezes excessivo |
| Valores derivados | Ótimo com computed |
Possível com operadores |
| Chamada HTTP única | Útil para guardar o resultado | Ótimo porque HttpClient devolve um Observable |
| Debounce da pesquisa | A combinar com RxJS | Ótimo |
| WebSocket | Útil para mostrar o último estado | Ótimo para o stream |
| Cancelamento de pedidos | Não é o seu ponto forte | Ótimo com switchMap |
| Templates Angular | Muito legível | Muito bom com o pipe async |
O que são os Angular Signals
Os Signals são um sistema de reatividade integrado no Angular. Um signal contém um valor e permite ao Angular seguir automaticamente quem lê esse valor.
Quando um componente lê um signal no template, o Angular pode atualizar essa parte da UI quando o signal muda. Isto torna o estado mais explícito e reduz a necessidade de muitos padrões baseados em Subject ou BehaviorSubject nos casos simples.
import { computed, signal } from '@angular/core';
const count = signal(0);
const double = computed(() => count() * 2);
function increment(): void {
count.update((value) => value + 1);
}
Neste exemplo, count é o estado principal, enquanto double é um estado derivado. Não temos de atualizar double à mão: o Angular recalcula-o quando count muda.
Quando os Signals são ideais
- Quando tens de gerir o estado local de um componente.
- Quando tens de mostrar ou esconder partes da UI.
- Quando tens de gerir filtros locais.
- Quando tens de calcular valores derivados.
- Quando queres evitar subscrições manuais desnecessárias.
- Quando queres criar uma store simples para uma funcionalidade.
- Quando o estado é síncrono e representa sempre “o valor atual”.
O que é o RxJS em Angular
O RxJS é uma biblioteca de programação reativa baseada em Observables. É usado no Angular há muitos anos e continua a ser central em muitas APIs do framework.
O HttpClient devolve Observables. Os reactive forms expõem valueChanges e statusChanges. O router expõe parâmetros, query params e eventos como Observables. Também WebSockets, timers, eventos DOM e streams complexos se modelam muito bem com RxJS.
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => this.api.searchProducts(query))
);
Este exemplo é típico: o utilizador escreve num campo de pesquisa, esperamos 300 milissegundos, ignoramos valores iguais e cancelamos automaticamente o pedido anterior se chegar uma nova pesquisa.
Quando o RxJS é ideal
- Quando tens eventos assíncronos no tempo.
- Quando precisas de debounce ou throttle.
- Quando tens de cancelar pedidos anteriores.
- Quando tens de combinar várias fontes assíncronas.
- Quando trabalhas com WebSockets ou streams ao vivo.
- Quando precisas de retry, timeout ou polling.
- Quando tens de orquestrar fluxos complexos.
Uma regra prática simples
Usa Signals para representar o estado atual da UI.
Usa computed para valores derivados do estado.
Usa RxJS para eventos assíncronos, streams no tempo e lógica de cancelamento.
Usa toSignal quando queres expor um Observable como estado legível no template.
Usa toObservable quando um signal tem de entrar num pipeline RxJS.
Exemplo real 1: lista de produtos com filtros locais
Imaginemos uma página de e-commerce com uma lista de produtos já carregados. O utilizador pode filtrar por texto, categoria e disponibilidade. Neste caso os Signals são uma escolha muito natural.
O estado principal é composto por produtos, query, categoria selecionada e a opção “só disponíveis”. Os produtos filtrados são estado derivado.
import { computed, inject, Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
export interface Product {
id: number;
name: string;
category: string;
price: number;
stock: number;
}
@Injectable()
export class ProductsStore {
private readonly api = inject(ProductsApiService);
private readonly _products = signal<Product[]>([]);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
private readonly _query = signal('');
private readonly _category = signal<string | null>(null);
private readonly _onlyAvailable = signal(false);
readonly products = this._products.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly query = this._query.asReadonly();
readonly category = this._category.asReadonly();
readonly onlyAvailable = this._onlyAvailable.asReadonly();
readonly filteredProducts = computed(() => {
const query = this._query().toLowerCase().trim();
const category = this._category();
const onlyAvailable = this._onlyAvailable();
return this._products().filter((product) => {
const matchesQuery = product.name.toLowerCase().includes(query);
const matchesCategory = !category || product.category === category;
const matchesAvailability = !onlyAvailable || product.stock > 0;
return matchesQuery && matchesCategory && matchesAvailability;
});
});
readonly total = computed(() => this.filteredProducts().length);
async loadProducts(): Promise<void> {
this._loading.set(true);
this._error.set(null);
try {
const products = await firstValueFrom(this.api.getProducts());
this._products.set(products);
} catch {
this._error.set('Impossibile caricare i prodotti.');
} finally {
this._loading.set(false);
}
}
setQuery(query: string): void {
this._query.set(query);
}
setCategory(category: string | null): void {
this._category.set(category);
}
setOnlyAvailable(value: boolean): void {
this._onlyAvailable.set(value);
}
}
Esta store é simples de ler. Os dados principais são signals. Os dados derivados são computed. O componente pode limitar-se a mostrar o estado.
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
@Component({
selector: 'app-products-page',
standalone: true,
template: `
<section class="products-page">
<h2>Prodotti</h2>
<input
type="search"
placeholder="Cerca prodotto..."
[value]="store.query()"
(input)="store.setQuery($any($event.target).value)"
/>
<label>
<input
type="checkbox"
[checked]="store.onlyAvailable()"
(change)="store.setOnlyAvailable($any($event.target).checked)"
/>
Solo disponibili
</label>
@if (store.loading()) {
<p>Caricamento...</p>
} @else if (store.error()) {
<p class="error">{{ store.error() }}</p>
} @else {
<p>Risultati: {{ store.total() }}</p>
@for (product of store.filteredProducts(); track product.id) {
<article class="product-card">
<h3>{{ product.name }}</h3>
<p>Categoria: {{ product.category }}</p>
<p>Prezzo: {{ product.price }} €</p>
</article>
}
}
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductsPageComponent implements OnInit {
readonly store = inject(ProductsStore);
ngOnInit(): void {
void this.store.loadProducts();
}
}
Usar RxJS para cada pequeno filtro seria possível, mas provavelmente mais verboso. Os Signals tornam o código mais direto.
Exemplo real 2: pesquisa no backend com debounce e cancelamento
Agora imaginemos uma pesquisa que consulta o backend a cada alteração do texto. Neste caso o RxJS torna-se muito mais adequado.
O motivo é simples: não queremos enviar um pedido por cada carácter. Queremos esperar um pequeno intervalo, ignorar valores iguais, cancelar o pedido anterior e tratar eventuais erros.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
of,
shareReplay,
startWith,
switchMap
} from 'rxjs';
@Component({
selector: 'app-product-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<section>
<h2>Cerca prodotti</h2>
<input
type="search"
[formControl]="searchControl"
placeholder="Scrivi almeno 2 caratteri..."
/>
@if (results$ | async; as results) {
@for (product of results; track product.id) {
<p>{{ product.name }}</p>
}
}
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductSearchComponent {
private readonly api = inject(ProductsApiService);
readonly searchControl = new FormControl('', { nonNullable: true });
readonly results$ = this.searchControl.valueChanges.pipe(
startWith(this.searchControl.value),
map((value) => value.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.length < 2) {
return of([]);
}
return this.api.searchProducts(query).pipe(
catchError(() => of([]))
);
}),
shareReplay({ bufferSize: 1, refCount: true })
);
}
Aqui o switchMap é essencial. Se o utilizador escrever “iph” e logo a seguir “iphone”, o pedido anterior pode ser cancelado e o componente usa apenas o resultado mais recente.
Este é um exemplo clássico em que o RxJS é mais natural do que os Signals.
Exemplo real 3: usar RxJS e Signals em conjunto
Em muitos casos, a melhor solução é usar RxJS para o fluxo assíncrono e Signals para expor o resultado à UI.
Podemos partir de um Observable e convertê-lo num signal com toSignal.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
of,
startWith,
switchMap
} from 'rxjs';
@Component({
selector: 'app-product-search-signal',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<section>
<input
type="search"
[formControl]="searchControl"
placeholder="Cerca prodotto..."
/>
@for (product of results(); track product.id) {
<p>{{ product.name }}</p>
}
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductSearchSignalComponent {
private readonly api = inject(ProductsApiService);
readonly searchControl = new FormControl('', { nonNullable: true });
private readonly results$ = this.searchControl.valueChanges.pipe(
startWith(this.searchControl.value),
map((value) => value.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.length < 2) {
return of([]);
}
return this.api.searchProducts(query).pipe(
catchError(() => of([]))
);
})
);
readonly results = toSignal(this.results$, {
initialValue: [] as Product[]
});
}
O pipeline continua a ser RxJS, porque tem de gerir tempo, debounce e cancelamento. O template, porém, lê um signal, por isso fica mais simples.
Exemplo real 4: detalhe do produto com parâmetro de rota
Outro cenário muito comum é uma página de detalhe. O produto a carregar depende do id presente na rota.
O parâmetro de rota é um stream. A chamada HTTP é um stream. A UI, pelo contrário, só quer o estado atual do produto.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, distinctUntilChanged, map, of, switchMap } from 'rxjs';
@Component({
selector: 'app-product-detail-page',
standalone: true,
template: `
@if (product(); as product) {
<article>
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
<p>Prezzo: {{ product.price }} €</p>
</article>
} @else {
<p>Prodotto non trovato.</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductDetailPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly api = inject(ProductsApiService);
private readonly product$ = this.route.paramMap.pipe(
map((params) => params.get('id')),
distinctUntilChanged(),
switchMap((id) => {
if (!id) {
return of(null);
}
return this.api.getProductById(id).pipe(
catchError(() => of(null))
);
})
);
readonly product = toSignal(this.product$, {
initialValue: null as Product | null
});
}
Aqui a melhor escolha não é só Signals nem só RxJS. A melhor escolha é usar os dois: RxJS para reagir à mudança de rota e Signals para mostrar o valor atual no template.
Exemplo real 5: carrinho e total da encomenda
Um carrinho de compras é um exemplo perfeito para Signals. O carrinho contém uma lista de linhas. A partir dessas linhas podemos derivar a quantidade total, o subtotal, os impostos e o total final.
import { computed, Injectable, signal } from '@angular/core';
export interface CartItem {
productId: number;
name: string;
quantity: number;
unitPrice: number;
}
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly totalQuantity = computed(() =>
this._items().reduce((total, item) => total + item.quantity, 0)
);
readonly subtotal = computed(() =>
this._items().reduce(
(total, item) => total + item.quantity * item.unitPrice,
0
)
);
readonly vat = computed(() => this.subtotal() * 0.22);
readonly total = computed(() => this.subtotal() + this.vat());
addItem(item: CartItem): void {
this._items.update((items) => {
const existing = items.find((current) => current.productId === item.productId);
if (!existing) {
return [...items, item];
}
return items.map((current) =>
current.productId === item.productId
? { ...current, quantity: current.quantity + item.quantity }
: current
);
});
}
removeItem(productId: number): void {
this._items.update((items) =>
items.filter((item) => item.productId !== productId)
);
}
clear(): void {
this._items.set([]);
}
}
Neste caso usar RxJS seria possível, mas os Signals são mais simples. O total é sempre derivado do estado atual. Não é preciso raciocinar sobre uma linha temporal de eventos.
Exemplo real 6: notificações ao vivo com WebSocket
As notificações ao vivo, pelo contrário, são um caso muito mais adequado ao RxJS. Um WebSocket emite mensagens no tempo. Pode cair, voltar a ligar-se, emitir erros e produzir eventos contínuos.
import { Injectable } from '@angular/core';
import { retry, share } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
export interface NotificationMessage {
id: string;
title: string;
body: string;
createdAt: string;
}
@Injectable({ providedIn: 'root' })
export class NotificationsService {
readonly messages$ = webSocket<NotificationMessage>(
'wss://example.com/notifications'
).pipe(
retry({ delay: 1000 }),
share()
);
}
Se quisermos mostrar a última notificação no template, podemos convertê-la num signal.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-notification-bell',
standalone: true,
template: `
@if (lastMessage(); as message) {
<button type="button">
🔔 {{ message.title }}
</button>
} @else {
<button type="button">🔔 Nessuna notifica</button>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationBellComponent {
private readonly notifications = inject(NotificationsService);
readonly lastMessage = toSignal(this.notifications.messages$, {
initialValue: null as NotificationMessage | null
});
}
O RxJS gere o stream. O signal expõe o último valor à UI. Este é o tipo de combinação mais saudável.
Interoperabilidade: toSignal e toObservable
O Angular oferece ferramentas para pôr Signals e RxJS a dialogar. As mais comuns são toSignal e toObservable.
toSignal converte um Observable num signal.
readonly user = toSignal(this.authService.user$, {
initialValue: null
});
toObservable converte um signal num Observable.
import { signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
readonly query = signal('');
private readonly query$ = toObservable(this.query);
readonly results = toSignal(
this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => this.api.searchProducts(query))
),
{ initialValue: [] as Product[] }
);
Esta técnica é muito útil quando o input do utilizador é gerido como signal, mas a pesquisa tem de usar operadores RxJS.
Erros comuns a evitar
1. Substituir o RxJS em todo o lado só porque existem Signals
Os Signals não eliminam o RxJS. O Angular continua a usar Observables em muitas APIs. Se tentares substituir todos os streams por signals, arriscas perder os operadores mais poderosos do RxJS.
2. Usar RxJS para cada pequeno estado local
Um booleano como isMenuOpen, um separador ativo ou um filtro local não precisam de um BehaviorSubject. Um signal é mais legível.
3. Fazer chamadas HTTP diretamente dentro de effect sem controlo
O effect é útil para efeitos secundários como sincronizar o localStorage, o título da página ou logs. Não deve tornar-se o sítio onde se esconde lógica assíncrona complexa.
// Da evitare in scenari complessi:
effect(() => {
const query = this.query();
this.api.searchProducts(query).subscribe();
});
Para debounce, cancelamento e tratamento de erros, um pipeline RxJS é mais claro.
4. Criar demasiados computed desnecessários
O computed é poderoso, mas não deve substituir todos os métodos ou todas as pequenas expressões. Usa-o quando o valor derivado é importante, reutilizado ou caro de calcular.
5. Converter para trás e para a frente sem motivo
Se tens um Observable usado apenas no template, o pipe async pode ser suficiente. Usa toSignal quando queres mesmo integrar esse valor no grafo dos Signals.
Árvore de decisão prática
- É estado local do componente? Usa Signals.
- É um valor derivado de outros estados? Usa
computed. - É um fluxo de eventos no tempo? Usa RxJS.
- Precisas de debounce? Usa RxJS.
- Precisas de cancelar pedidos anteriores? Usa RxJS com
switchMap. - Precisas de mostrar o último valor no template? Considera
toSignal. - É uma lista já carregada para filtrar localmente? Signals.
- É uma pesquisa remota enquanto o utilizador escreve? RxJS.
- É um carrinho com totais derivados? Signals.
- É um WebSocket? RxJS.
- É um formulário com validação assíncrona? RxJS, mais Signals se for preciso estado de UI.
- É uma store simples de funcionalidade? Signals.
- É a orquestração de várias chamadas HTTP dependentes? RxJS.
Arquitetura recomendada numa aplicação real
Num projeto Angular profissional, uma boa estratégia é separar a lógica numa camada data-access. Os componentes não devem saber demasiado sobre HTTP, cache, novas tentativas ou mapeamento de dados.
src/app/features/products/
products.routes.ts
pages/
products-page.component.ts
product-detail-page.component.ts
components/
product-card.component.ts
product-filters.component.ts
data-access/
products-api.service.ts
products-store.service.ts
models/
product.model.ts
No ficheiro do API service podes usar RxJS, porque o HttpClient do Angular devolve Observables.
Na store podes guardar o resultado em Signals, criar computed para filtros e totais e expor métodos simples ao componente.
No componente podes ler Signals no template, usar o pipe async quando trabalhas diretamente com Observables, ou converter com toSignal quando isso melhora a legibilidade.
Signals e RxJS não se excluem. Os Signals tornam o Angular mais simples para o estado local, o estado derivado e a UI. O RxJS continua a ser essencial para fluxos assíncronos, streams, cancelamentos, retry, debounce e composição avançada.
A melhor escolha é usar a ferramenta certa no sítio certo. Se estás a representar um valor atual, escolhe Signals. Se estás a modelar eventos no tempo, escolhe RxJS. Se precisas dos dois, combina-os sem forçar.