Voltar ao Blog
Angular Development24 de dezembro de 20259 min de leitura

Operadores RxJS Que Você Realmente Usa em Apps Angular em Produção

DR

Doug Ribeiro

9 min de leitura

Escrevo Angular profissionalmente desde 2017. Já vi times se afogarem em cadeias de operadores que ninguém conseguia debugar, e já vi times subutilizarem RxJS e acabarem com race conditions que levaram semanas para rastrear. A distância entre os dois extremos é menor do que as pessoas pensam. Você precisa de cerca de dez operadores, entendidos profundamente.

switchMap: Cancela e Substitui

switchMap é o operador que eu pego primeiro quando input do usuário dispara uma chamada HTTP. Quando um novo valor chega da source, switchMap cancela a subscrição do observable interno anterior e inicia um novo. Para busca conforme digita, isso significa que a requisição em andamento da tecla anterior é cancelada e substituída.

Usei isso extensivamente na plataforma de cartão de crédito da Wonderlabs. Os filtros de cartões e transações refaziam a busca a cada mudança. Sem switchMap, respostas chegariam fora de ordem e sobrescreveriam resultados mais novos com mais antigos. Esse é um bug sutil e difícil de reproduzir em produção.

typescript
@Component({ selector: 'app-product-search', standalone: true, template: '' })
export class ProductSearchComponent {
  private productService = inject(ProductService);
  searchControl = new FormControl('');
  results: Product[] = [];

  // In the constructor, so bare takeUntilDestroyed() has an injection context
  constructor() {
    this.searchControl.valueChanges.pipe(
      debounceTime(350),
      distinctUntilChanged(),
      switchMap(query => this.productService.search(query ?? '')),
      takeUntilDestroyed()
    ).subscribe(results => this.results = results);
  }
}

exhaustMap: Ignora Até Terminar

exhaustMap é o subestimado. Enquanto um observable interno está ativo, ele ignora todas as novas emissões da source. O caso de uso canônico é um botão de submit de formulário. Coloque exhaustMap atrás do botão de submit de um formulário de solicitação de empréstimo: se o usuário der duplo clique ou clicar novamente durante a chamada de API, o segundo clique é silenciosamente ignorado. A requisição não é duplicada.

typescript
@Component({ selector: 'app-application-form', standalone: true, template: '' })
export class ApplicationFormComponent {
  private lendingApi = inject(LendingApiService);
  private router = inject(Router);
  submitClicks$ = new Subject<void>();

  constructor() {
    this.submitClicks$.pipe(
      exhaustMap(() => this.lendingApi.submitApplication(this.form.value).pipe(
        catchError(err => {
          this.handleError(err);
          return EMPTY;
        })
      )),
      takeUntilDestroyed()
    ).subscribe(confirmation => {
      this.router.navigate(['/confirmation', confirmation.id]);
    });
  }
}

Note o catchError dentro da função de projeção do exhaustMap. Se o erro se propagar para o pipe externo, o exhaustMap termina e cliques futuros param de funcionar. Sempre capture dentro do observable interno.

mergeMap: Execute Tudo em Paralelo

Onde switchMap cancela e exhaustMap ignora, mergeMap subscreve a cada observable interno concorrentemente. Cada emissão da source produz uma subscrição ativa. Use quando a ordem não importa e você quer que todas as emissões sejam tratadas.

typescript
// Upload multiple documents in parallel for a loan application
const uploads$ = from(selectedDocuments).pipe(
  mergeMap(doc => this.documentService.upload(doc).pipe(
    catchError(() => of({ id: doc.id, status: 'failed' }))
  ))
);

uploads$.subscribe(result => this.updateUploadStatus(result));

concatMap: Enfileire e Preserve a Ordem

concatMap é o mergeMap com uma fila. Ele assina um observable interno por vez e espera ele completar antes de começar o próximo. Use quando a ordem faz parte do contrato: uploads sequenciais que o servidor recompõe depois, eventos de auditoria que precisam chegar na ordem em que o usuário produziu, um wizard que salva cada etapa antes da próxima começar. O custo é back pressure. Se a source emite mais rápido do que o observable interno completa, a fila cresce sem limite e nada te avisa. Se você não consegue argumentar que a source é mais lenta que o trabalho, use mergeMap com um limite de concorrência no lugar.

typescript
@Component({ selector: 'app-chunked-upload', standalone: true, template: '' })
export class ChunkedUploadComponent {
  private uploadService = inject(UploadService);
  private destroyRef = inject(DestroyRef);

  send(chunks: Chunk[]): void {
    // Each chunk must land before the next one starts
    from(chunks).pipe(
      concatMap(chunk => this.uploadService.sendChunk(chunk)),
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(ack => this.markChunkStored(ack.index));
  }

  sendUnordered(chunks: Chunk[]): void {
    // mergeMap with a concurrency cap when order does not matter but load does
    from(chunks).pipe(
      mergeMap(chunk => this.uploadService.sendChunk(chunk), 4),
      takeUntilDestroyed(this.destroyRef)
    ).subscribe();
  }
}
switchMap / mergeMapswitchMap: cancela o observable…switchMap: use em busca e autoc…A última emissão vence. As ante…mergeMap: roda todos os observa…mergeMap: use em requisições pa…Risco: respostas fora de ordem …exhaustMap / concatMapexhaustMap: ignora novos enquan…exhaustMap: use em submit de fo…Evita duplo submit. A requisiçã…concatMap: enfileira e roda em …concatMap: use em uploads seque…Risco: a fila pode crescer sem …VS

combineLatest: Reaja a Múltiplos Streams de Uma Vez

combineLatest emite sempre que qualquer uma de suas sources emite, combinando o valor mais recente de cada uma. Usei isso nos dashboards de investimento em tempo real da GFT. Um dashboard filtrado por intervalo de datas, região e tipo de produto precisa refazer a busca sempre que qualquer filtro muda.

typescript
@Component({ selector: 'app-report-dashboard', standalone: true, template: '' })
export class ReportDashboardComponent {
  private reportService = inject(ReportService);
  reportData: ReportRow[] = [];

  constructor() {
    combineLatest([
      this.dateRangeControl.valueChanges.pipe(startWith(this.dateRangeControl.value)),
      this.regionControl.valueChanges.pipe(startWith(this.regionControl.value)),
      this.productControl.valueChanges.pipe(startWith(this.productControl.value))
    ]).pipe(
      debounceTime(200),
      switchMap(([dateRange, region, product]) =>
        this.reportService.fetch({ dateRange, region, product })
      ),
      takeUntilDestroyed()
    ).subscribe(data => this.reportData = data);
  }
}

O startWith em cada controle é importante. combineLatest não emite até que todas as sources tenham emitido pelo menos uma vez. Sem startWith, o dashboard espera o usuário interagir com todos os três filtros antes de carregar qualquer coisa.

forkJoin: Espere Todos Completarem

forkJoin é o Promise.all para observables. Ele espera todas as sources completarem e emite seus valores finais juntos. Eu uso em resolvers de rota e lógica de inicialização.

typescript
@Component({ selector: 'app-lending-page', standalone: true, template: '' })
export class LendingPageComponent implements OnInit {
  private destroyRef = inject(DestroyRef);

  ngOnInit(): void {
    // Page init: load user, permissions, and feature config before rendering.
    // Outside the constructor, takeUntilDestroyed needs the DestroyRef passed in.
    forkJoin({
      user: this.userService.getCurrentUser(),
      permissions: this.authService.getPermissions(),
      config: this.featureService.getLendingConfig()
    }).pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(({ user, permissions, config }) => {
      this.initializePage(user, permissions, config);
    });
  }
}

Uma pegadinha: se qualquer source no forkJoin der erro, o forkJoin inteiro dá erro. Envolva observables individuais com catchError se você precisa de resultados parciais em caso de falha.

debounceTime e distinctUntilChanged: Sempre Juntos

Esses dois quase nunca são usados separadamente. debounceTime espera por um período de silêncio. distinctUntilChanged descarta a emissão se o valor não mudou. Juntos, eles previnem o padrão mais comum de abuso de API: bombardear um endpoint de busca a cada tecla com o mesmo valor repetido.

typescript
@Component({ selector: 'app-filter-panel', standalone: true, template: '' })
export class FilterPanelComponent {
  private api = inject(SearchApiService);
  filterControl = new FormControl('');
  results: SearchResult[] = [];

  constructor() {
    this.filterControl.valueChanges.pipe(
      debounceTime(400),
      distinctUntilChanged(),
      switchMap(term => this.api.filter(term ?? '')),
      takeUntilDestroyed()
    ).subscribe(results => this.results = results);
  }
}

Gerenciamento de Subscrições: takeUntilDestroyed

O padrão antigo era um Subject, takeUntil e ngOnDestroy. Angular 16 introduziu takeUntilDestroyed do @angular/core/rxjs-interop, que se conecta ao lifecycle de destroy do componente automaticamente. Em qualquer codebase rodando Angular 16 ou superior, esse é o padrão que eu uso.

typescript
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { inject, DestroyRef } from '@angular/core';

@Component({ standalone: true, template: '' })
export class ExampleComponent {
  // Call outside of a constructor needs explicit DestroyRef
  private destroyRef = inject(DestroyRef);

  ngOnInit() {
    this.someObservable$.pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe();
  }

  // Or in constructor, no argument needed:
  constructor() {
    this.otherObservable$.pipe(
      takeUntilDestroyed() // Infers context automatically
    ).subscribe();
  }
}

O Operador Que as Pessoas Esquecem: Posicionamento do catchError

Isso não é uma dica de operador, mas um erro de operador. Quando você coloca catchError no nível do pipe externo e um erro ocorre, o stream inteiro termina. Se esse stream alimenta um elemento de UI, ele para de responder para sempre. Sempre coloque catchError dentro do observable interno (dentro do switchMap, exhaustMap, etc.) e retorne EMPTY ou um valor fallback. O stream externo permanece vivo.

typescript
// Wrong: error kills the entire search stream
this.searchControl.valueChanges.pipe(
  switchMap(q => this.api.search(q)),
  catchError(err => { // Stream terminates here. No more searches work.
    this.showError(err);
    return of([]);
  })
);

// Correct: error stays inside the inner observable
this.searchControl.valueChanges.pipe(
  switchMap(q => this.api.search(q).pipe(
    catchError(err => {
      this.showError(err);
      return of([]); // inner observable completes cleanly
    })
  ))
);

O objetivo não é conhecer todo operador RxJS. É conhecer um conjunto pequeno profundamente o suficiente para pegar o certo sem pensar.

switchMap para requisições canceláveis. exhaustMap para envios de formulário. mergeMap para operações independentes em paralelo. concatMap quando a ordem faz parte do contrato. forkJoin para carregamento de dados em nível de página. combineLatest para dashboards com múltiplos filtros. debounceTime mais distinctUntilChanged em todo stream de input do usuário. takeUntilDestroyed em toda subscrição. catchError dentro do observable interno. Essa é a lista completa.

Referências

AngularRxJSTypeScriptFrontend