Todos os artigos

De RxJS para Signals: guia prático para migrar o estado em Angular

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.

RxJSSignalsNotas
BehaviorSubject<T>signal<T>()Valor atual, legível de forma síncrona
combineLatest() + map()computed()Dependências seguidas automaticamente
.subscribe() para efeitos secundárioseffect()Só para o mundo exterior
Pipe asyncleitura de valor() no templateNenhuma subscrição para gerir
debounceTime, switchMapficam em RxJSPonte 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 takeUntil para uma subscrição continuar viva depois de o componente ser destruído.
  • Estado duplicado: visible e count sã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 como debounceTime.

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

  1. Usa Signals para o estado síncrono e a UI: valores atuais, filtros, seleções, contagens.
  2. Usa RxJS para eventos assíncronos e fluxos no tempo: HTTP, WebSocket, debounce, cancelamento. Os operadores RxJS continuam a ser preciosos.
  3. Usa computed para tudo o que é derivado, nunca um effect que copia valores.
  4. Põe os dois mundos a cooperar com rxjs-interop, em vez de reescrever tudo.
  5. 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.

💬 Notas dos leitores

0 notas

Escreva uma nota

Partilhe a sua opinião, uma sugestão ou um elogio

Notas recentes

Ainda não há notas. Seja o primeiro a comentar!