Especialização em Front-End com Angular • Demonstrações Práticas Compiláveis


💻 Código de Exemplo 11 — Busca Assíncrona Reativa com debounceTime, switchMap e catchError

Este exemplo apresenta a construção da pipeline clássica de busca assíncrona reativa no Angular 18+, utilizando operadores essenciais do RxJS para controlar a taxa de digitação (debounceTime), filtrar termos repetidos (distinctUntilChanged), cancelar requisições anteriores obsoletas (switchMap) e capturar falhas com elegância (catchError).

// src/app/components/enterprise-search-box.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { Subject, of, Observable } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, catchError, tap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

interface SearchResultItem {
  id: string;
  name: string;
  category: string;
  latencyMs: number;
}

@Component({
  selector: 'app-enterprise-search-box',
  standalone: true,
  imports: [CommonModule, AsyncPipe],
  template: `
    <div class="max-w-xl mx-auto p-6 bg-white border border-slate-200 rounded-2xl shadow-sm space-y-5">
      <div class="border-b pb-3">
        <h2 class="text-xl font-bold text-slate-800">🔍 Pesquisa Reativa com Operadores RxJS</h2>
        <p class="text-xs text-slate-500">Pipeline: debounceTime(350) ➔ distinctUntilChanged ➔ switchMap</p>
      </div>

      <!-- Campo de Digitação Conectado ao Subject -->
      <div class="relative">
        <input
          type="text"
          #searchInput
          (input)="onSearchInput(searchInput.value)"
          placeholder="Digite o nome de um serviço ou nó..."
          class="w-full border px-4 py-2.5 rounded-xl text-sm outline-none focus:ring-2 focus:ring-red-500 pl-10"
        />
        <span class="absolute left-3.5 top-3 text-slate-400 text-sm">🔎</span>
      </div>

      <!-- Indicador de Carregamento (Loading Signal) -->
      @if (isLoading()) {
        <div class="flex items-center gap-2 text-xs text-slate-500 py-1">
          <span class="w-3.5 h-3.5 border-2 border-red-600 border-t-transparent rounded-full animate-spin"></span>
          <span>Consultando catálogo de serviços via switchMap...</span>
        </div>
      }

      <!-- Erro Tratado via catchError -->
      @if (errorMessage()) {
        <div class="p-3 bg-red-50 border border-red-200 text-red-700 text-xs rounded-xl">
          ⚠️ {{ errorMessage() }}
        </div>
      }

      <!-- Resultados Consumidos com AsyncPipe -->
      <div class="space-y-2">
        <h4 class="text-xs font-bold uppercase text-slate-400">Resultados Encontrados:</h4>
        <div class="border rounded-xl divide-y overflow-hidden max-h-56 overflow-y-auto">
          @if (searchResults$ | async; as results) {
            @for (item of results; track item.id) {
              <div class="p-3 flex justify-between items-center hover:bg-slate-50 text-xs">
                <div>
                  <span class="font-bold text-slate-800">{{ item.name }}</span>
                  <span class="ml-2 text-[10px] px-2 py-0.5 rounded bg-slate-100 text-slate-600 uppercase font-mono">
                    {{ item.category }}
                  </span>
                </div>
                <span class="font-mono text-slate-400 text-[11px]">{{ item.latencyMs }} ms</span>
              </div>
            } @empty {
              <div class="p-4 text-center text-xs text-slate-400">
                Nenhum serviço atende ao termo pesquisado.
              </div>
            }
          }
        </div>
      </div>
    </div>
  `
})
export class EnterpriseSearchBoxComponent {
  // 1. Subject para emissão de termos digitados pelo usuário
  private searchTerm$ = new Subject<string>();

  isLoading = signal<boolean>(false);
  errorMessage = signal<string | null>(null);

  // 2. Pipeline Reativa Encadeada
  readonly searchResults$: Observable<SearchResultItem[]> = this.searchTerm$.pipe(
    // A. Aguarda 350ms de pausa na digitação do usuário
    debounceTime(350),
    // B. Ignora se o termo for idêntico ao anterior (ex: pressionar Shift ou setas)
    distinctUntilChanged(),
    // C. Ativa indicador de loading
    tap(() => {
      this.isLoading.set(true);
      this.errorMessage.set(null);
    }),
    // D. switchMap: Cancela a requisição anterior se o usuário digitar algo novo!
    switchMap((term) =>
      this.mockApiSearch(term).pipe(
        // E. catchError: Trata exceções sem matar o stream principal da busca
        catchError((err) => {
          this.errorMessage.set(`Erro na consulta: ${err.message}`);
          return of([]); // Retorna array vazio seguro
        })
      )
    ),
    // F. Desativa loading após resolução
    tap(() => this.isLoading.set(false)),
    // G. takeUntilDestroyed: Cancela todo o stream ao desmontar o componente
    takeUntilDestroyed()
  );

  onSearchInput(value: string): void {
    this.searchTerm$.next(value.trim());
  }

  // Simulação de chamada HTTP com latência e cancelamento
  private mockApiSearch(term: string): Observable<SearchResultItem[]> {
    if (!term) return of([]);

    return new Observable((observer) => {
      console.log(`[API Search] Disparando requisição HTTP para termo: "${term}"`);
      const timer = setTimeout(() => {
        if (term.toLowerCase() === 'erro') {
          observer.error(new Error('Falha de timeout no cluster de pesquisa.'));
          return;
        }

        const mockDatabase: SearchResultItem[] = [
          { id: 'srv-01', name: 'Servidor de Autenticação OAuth2', category: 'Security', latencyMs: 24 },
          { id: 'srv-02', name: 'Gateway de Pagamentos PIX / TED', category: 'Finance', latencyMs: 18 },
          { id: 'srv-03', name: 'Cluster de Mensageria Kafka', category: 'Streaming', latencyMs: 42 },
          { id: 'srv-04', name: 'Serviço de Auditoria Fiscal', category: 'Compliance', latencyMs: 31 }
        ];

        const filtered = mockDatabase.filter((i) =>
          i.name.toLowerCase().includes(term.toLowerCase())
        );

        observer.next(filtered);
        observer.complete();
      }, 500);

      // Função de Teardown: invocada pelo switchMap quando cancela a requisição!
      return () => {
        console.log(`[API Search] Requisição anterior para "${term}" foi cancelada pelo switchMap.`);
        clearTimeout(timer);
      };
    });
  }
}

🖥️ Saída / Comportamento Esperado

Cenário 1 — Digitação Rápida:
O usuário digita rapidamente "Serviço":
O operador debounceTime(350) aguarda a pausa do usuário.
Apenas uma única chamada HTTP é disparada ao término dos 350ms.

Cenário 2 — Cancelamento de Race Conditions com switchMap:
O usuário digita "Servidor" e, 200ms depois (antes da API responder), digita "Cluster":
Console:
"[API Search] Requisição anterior para 'Servidor' foi cancelada pelo switchMap."
"[API Search] Disparando requisição HTTP para termo: 'Cluster'"
Apenas o resultado do termo mais recente ("Cluster de Mensageria Kafka") é renderizado.

Cenário 3 — Tratamento de Erro com catchError:
O usuário digita "erro":
catchError intercepta o erro, alimenta errorMessage() e retorna of([]).
O stream continua vivo e pronto para novas pesquisas quando o usuário apagar o texto.

🔍 Destaques da Implementação

  1. switchMap Anti-Race-Condition: Cancela o Observable interno anterior chamando sua função de teardown imediatamente.
  2. catchError com Fallback: Impede que um erro HTTP 500 encerre definitivamente o Subject da barra de busca.
  3. distinctUntilChanged: Evita disparar requisições inúteis quando teclas de navegação ou modificadores são pressionados.