Introdução: a revolução da reatividade em Angular
Desde as primeiras versões do Angular, o RxJS foi o motor da reatividade: chamadas HTTP, formulários, router e eventos passavam todos por Observables. Uma ferramenta poderosíssima, mas exigente: para mostrar um simples valor derivado eram precisos subjects, operadores e subscrições para fechar.
Com os Signals, a equipa do Angular introduziu um modelo mais simples para o estado da aplicação: um valor que se lê, se atualiza e do qual outros valores dependem automaticamente. O resultado é menos código, uma leitura mais imediata e as bases para um Angular sem Zone.js. Neste artigo vemos como e quando passar o estado de RxJS para Signals, com uma refatorização completa.
RxJS vs Signals: duas ferramentas para problemas diferentes
A distinção mais útil é esta: o RxJS descreve eventos ao longo do tempo — cliques, mensagens WebSocket, respostas HTTP, teclas a abrandar — enquanto os Signals descrevem o estado atual e os valores que dele derivam. Não são concorrentes: resolvem problemas diferentes, como expliquei com mais detalhe neste artigo.
| RxJS | Signals | Notas |
|---|---|---|
BehaviorSubject<T> | signal<T>() | Valor atual, legível de forma síncrona |
combineLatest() + map() | computed() | Dependências seguidas automaticamente |
.subscribe() para efeitos secundários | effect() | Só para o mundo exterior |
Pipe async | leitura de valor() no template | Nenhuma subscrição para gerir |
debounceTime, switchMap | ficam em RxJS | Ponte com toObservable |
Há também uma vantagem na deteção de alterações: quando um template lê um signal, o Angular sabe exatamente que componentes dependem desse valor e atualiza apenas esses, em vez de voltar a verificar toda a árvore de componentes a cada evento.
Anatomia da refatorização: um caso real
O cenário é clássico: um dashboard com a lista de produtos, um campo de pesquisa de texto e a contagem dos elementos filtrados. Eis a versão "old-school" com RxJS:
@Component({ /* ... */ })
export class ProductDashboardComponent implements OnInit, OnDestroy {
private readonly products$ = new BehaviorSubject<Product[]>([]);
private readonly filter$ = new BehaviorSubject<string>('');
private readonly destroy$ = new Subject<void>();
visible: Product[] = [];
count = 0;
constructor(private api: ProductApi) {}
ngOnInit(): void {
this.api.list().pipe(takeUntil(this.destroy$))
.subscribe(list => this.products$.next(list));
combineLatest([this.products$, this.filter$.pipe(debounceTime(300))])
.pipe(
map(([list, term]) =>
list.filter(p => p.name.toLowerCase().includes(term.toLowerCase()))),
takeUntil(this.destroy$),
)
.subscribe(visible => {
this.visible = visible;
this.count = visible.length;
});
}
onFilter(term: string): void { this.filter$.next(term); }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
Funciona, mas tem os defeitos típicos desta abordagem:
- Muito código acessório: dois subjects, um subject de destruição, duas subscrições e um
ngOnDestroy. - Um fluxo difícil de seguir: para perceber de onde vem
counté preciso ler toda a cadeia de operadores. - Risco de memory leaks: basta esquecer um
takeUntilpara uma subscrição continuar viva depois de o componente ser destruído. - Estado duplicado:
visibleecountsão cópias a manter sincronizadas à mão.
Passo a passo: o estado com Signals
Passo 1: valores e inputs passam a signals
Cada BehaviorSubject que representa estado passa a ser um writable signal. Atualiza-se com .set() quando se substitui o valor e com .update() quando o novo valor depende do anterior:
readonly products = signal<Product[]>([]);
readonly filter = signal('');
onFilter(term: string): void {
this.filter.set(term); // replace the value
}
addProduct(p: Product): void {
this.products.update(list => [...list, p]); // derive from the previous one
}
Passo 2: a lógica derivada passa a computed
O combineLatest com map passa a ser um computed(). As dependências não se declaram: o Angular descobre-as ao seguir os signals que são lidos. Além disso, um computed é preguiçoso e memorizado: só é recalculado quando uma dependência muda, e só se alguém o ler.
readonly visible = computed(() => {
const term = this.filter().toLowerCase();
return this.products().filter(p => p.name.toLowerCase().includes(term));
});
readonly count = computed(() => this.visible().length);
// Template: no async pipe, no subscriptions
// @for (p of visible(); track p.id) { <app-product-card [product]="p" /> }
// <span>{{ count() }} products</span>
visible e count deixam de ser cópias a sincronizar: estão sempre coerentes por construção.
Passo 3: efeitos secundários, com effect
O effect() serve para falar com o mundo exterior: guardar no localStorage, escrever um log, sincronizar uma biblioteca de terceiros como um gráfico ou um mapa. Não serve para manter dois signals alinhados: nesse caso a resposta é quase sempre um computed.
constructor() {
// OK: a side effect towards the outside world
effect(() => localStorage.setItem('dashboard.filter', this.filter()));
}
// Anti-pattern: an effect that copies one signal into another
effect(() => this.total.set(this.visible().length));
// Better: derive it, and let Angular keep it in sync
readonly total = computed(() => this.visible().length);
A ponte entre os dois mundos: rxjs-interop
As chamadas ao backend — uma API REST ou NestJS — continuam naturalmente a ser Observables: o HttpClient devolve streams, e é aí que fazem falta o cancelamento, as novas tentativas e os operadores temporais. O pacote @angular/core/rxjs-interop oferece duas pontes:
toSignal(): transforma um stream, por exemplo uma resposta HTTP, num signal para usar diretamente no template.toObservable(): transforma um signal num stream quando são precisos operadores temporais comodebounceTime.
Eis uma pesquisa em tempo real que usa os dois: o campo de pesquisa é um signal, o debounce e o cancelamento dos pedidos ficam no RxJS, e o resultado volta ao template como signal. Ambas as funções têm de ser chamadas num contexto de injeção, por exemplo como inicializadores de campo.
private readonly api = inject(ProductApi);
readonly query = signal('');
// signal → Observable (time operators) → signal (template)
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q)),
),
{ initialValue: [] as Product[] },
);
// Template: <input (input)="query.set($any($event.target).value)" />
// @for (p of results(); track p.id) { ... }
Padrão arquitetural: uma store baseada em Signals
Quando o estado é preciso em vários componentes, a lógica passa para um service. A regra é encapsular: o signal editável fica privado, para fora expõe-se uma versão só de leitura com .asReadonly(), e as alterações passam apenas pelos métodos do service.
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly(); // read-only from outside
readonly total = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.qty, 0));
add(item: CartItem): void { this._items.update(list => [...list, item]); }
clear(): void { this._items.set([]); }
}
Para aplicações maiores, a Signal Store de @ngrx/signals leva o mesmo modelo ao nível global, com uma estrutura padrão para estado, valores derivados e métodos.
Conclusões e checklist
- Usa Signals para o estado síncrono e a UI: valores atuais, filtros, seleções, contagens.
- Usa RxJS para eventos assíncronos e fluxos no tempo: HTTP, WebSocket, debounce, cancelamento. Os operadores RxJS continuam a ser preciosos.
- Usa
computedpara tudo o que é derivado, nunca umeffectque copia valores. - Põe os dois mundos a cooperar com rxjs-interop, em vez de reescrever tudo.
- Migra um componente de cada vez, começando pelos que têm mais subscrições manuais.
Já migraste parte da tua aplicação para Signals, ou tens dúvidas sobre onde traçar a fronteira com o RxJS? Deixa um comentário aqui em baixo com a tua experiência: as perguntas mais interessantes vão tornar-se ideias para os próximos artigos.
