CodePráticov3.9.0
AngularRxJS

Como fazer pesquisa com debounce, switchMap, loading e paginação no Angular

Uma tela de pesquisa normalmente precisa controlar termo, página atual, loading, erro e chamadas HTTP. Se cada evento for tratado separadamente, é fácil disparar requisições duplicadas ou exibir uma resposta antiga. Neste exemplo vamos transformar tudo em um único fluxo RxJS.

Objetivo: quando o usuário digitar um novo termo, a página volta para 1; quando trocar apenas a página, o termo é preservado; e o switchMap mantém somente a requisição correspondente ao estado mais recente.

O problema comum

Um código imperativo costuma evoluir para algo assim:

pesquisar(): void {
  this.pagina = 1;
  this.carregar();
}

mudarPagina(pagina: number): void {
  this.pagina = pagina;
  this.carregar();
}

Ele funciona em telas simples, mas começa a ficar difícil de manter quando entram debounce, filtros, cancelamento de HTTP, loading e tratamento de erro. Também é comum resetar a página emitindo um evento separado e acabar fazendo duas chamadas para a API.

1. Defina o formato da resposta paginada

export interface PaginaResponse<T> {
  dados: T[];
  pagina: number;
  porPagina: number;
  total: number;
  totalPaginas: number;
}

O backend pode usar outros nomes, mas a ideia é a mesma: devolver os registros e os dados necessários para montar a paginação.

2. Service com HttpClient

export interface ParametrosPesquisa {
  termo: string;
  pagina: number;
  porPagina: number;
}

buscar(paramsPesquisa: ParametrosPesquisa): Observable<PaginaResponse<Aluno>> {
  const params = new HttpParams()
    .set('q', paramsPesquisa.termo)
    .set('pagina', paramsPesquisa.pagina)
    .set('porPagina', paramsPesquisa.porPagina);

  return this.http.get<PaginaResponse<Aluno>>(
    '/api/alunos',
    { params }
  );
}

O service apenas monta a chamada e retorna a Observable. Não faça subscribe() aqui, porque o componente precisa controlar o fluxo com switchMap.

3. Transforme termo e página em ações

Vamos usar um FormControl para o termo e um Subject para os cliques da paginação:

pesquisa = new FormControl('', { nonNullable: true });

private readonly paginaSelecionada$ = new Subject<number>();

O termo recebe debounceTime e distinctUntilChanged:

private readonly termo$ = this.pesquisa.valueChanges.pipe(
  map(valor => valor.trim()),
  debounceTime(400),
  distinctUntilChanged(),
  startWith(this.pesquisa.value.trim()),
  map(termo => ({
    tipo: 'termo' as const,
    termo
  }))
);

A troca de página vira outra ação:

private readonly pagina$ = this.paginaSelecionada$.pipe(
  distinctUntilChanged(),
  map(pagina => ({
    tipo: 'pagina' as const,
    pagina
  }))
);

4. Una tudo em um único estado de pesquisa

O ponto importante está aqui: quando o termo muda, o mesmo estado já volta para a página 1. Não precisamos disparar um segundo evento apenas para resetar a página.

private readonly parametros$ = merge(
  this.termo$,
  this.pagina$
).pipe(
  scan(
    (estado, acao) => {
      if (acao.tipo === 'termo') {
        return {
          ...estado,
          termo: acao.termo,
          pagina: 1
        };
      }

      return {
        ...estado,
        pagina: acao.pagina
      };
    },
    {
      termo: '',
      pagina: 1,
      porPagina: 20
    }
  ),

  distinctUntilChanged(
    (anterior, atual) =>
      anterior.termo === atual.termo &&
      anterior.pagina === atual.pagina &&
      anterior.porPagina === atual.porPagina
  ),

  shareReplay({
    bufferSize: 1,
    refCount: true
  })
);
Por que isso evita chamadas duplicadas? O reset da página acontece dentro da mesma transição de estado do termo. Não fazemos pagina$.next(1) dentro de outro fluxo, o que poderia produzir uma emissão para o termo novo com a página antiga e outra logo depois com a página 1.

5. Faça a chamada com switchMap

Agora cada estado de pesquisa gera uma chamada HTTP:

readonly vm$ = this.parametros$.pipe(
  switchMap(parametros =>
    this.alunoService.buscar(parametros).pipe(
      map(resultado => ({
        carregando: false,
        erro: '',
        resultado
      })),

      catchError(() => of({
        carregando: false,
        erro: 'Não foi possível carregar os alunos.',
        resultado: null
      })),

      startWith({
        carregando: true,
        erro: '',
        resultado: null
      })
    )
  ),

  shareReplay({
    bufferSize: 1,
    refCount: true
  })
);

Se o usuário digitar novamente antes da resposta, switchMap cancela a inscrição da requisição anterior e passa a observar somente a chamada do estado mais recente.

6. Por que o loading está dentro do fluxo?

É tentador controlar carregando = true/false com tap() e finalize(). Mas, quando switchMap cancela uma requisição, o finalize() da chamada cancelada também executa e pode desligar o loading enquanto a próxima requisição já está ativa.

Por isso o exemplo usa um pequeno view model:

{ carregando: true,  erro: '', resultado: null }
{ carregando: false, erro: '', resultado: pagina }
{ carregando: false, erro: '...', resultado: null }

Cada nova requisição começa emitindo seu próprio estado de loading. Isso evita a disputa entre booleans de chamadas diferentes.

7. Componente completo

import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
import {
  Subject,
  catchError,
  debounceTime,
  distinctUntilChanged,
  map,
  merge,
  of,
  scan,
  shareReplay,
  startWith,
  switchMap
} from 'rxjs';

@Component({
  selector: 'app-pesquisa-aluno',
  templateUrl: './pesquisa-aluno.component.html'
})
export class PesquisaAlunoComponent {

  pesquisa = new FormControl('', { nonNullable: true });

  private readonly paginaSelecionada$ = new Subject<number>();

  private readonly termo$ = this.pesquisa.valueChanges.pipe(
    map(valor => valor.trim()),
    debounceTime(400),
    distinctUntilChanged(),
    startWith(this.pesquisa.value.trim()),
    map(termo => ({ tipo: 'termo' as const, termo }))
  );

  private readonly pagina$ = this.paginaSelecionada$.pipe(
    distinctUntilChanged(),
    map(pagina => ({ tipo: 'pagina' as const, pagina }))
  );

  private readonly parametros$ = merge(
    this.termo$,
    this.pagina$
  ).pipe(
    scan(
      (estado, acao) => {
        if (acao.tipo === 'termo') {
          return {
            ...estado,
            termo: acao.termo,
            pagina: 1
          };
        }

        return {
          ...estado,
          pagina: acao.pagina
        };
      },
      {
        termo: '',
        pagina: 1,
        porPagina: 20
      }
    ),

    distinctUntilChanged(
      (a, b) =>
        a.termo === b.termo &&
        a.pagina === b.pagina &&
        a.porPagina === b.porPagina
    ),

    shareReplay({ bufferSize: 1, refCount: true })
  );

  readonly vm$ = this.parametros$.pipe(
    switchMap(parametros =>
      this.alunoService.buscar(parametros).pipe(
        map(resultado => ({
          carregando: false,
          erro: '',
          resultado
        })),
        catchError(() => of({
          carregando: false,
          erro: 'Não foi possível carregar os alunos.',
          resultado: null
        })),
        startWith({
          carregando: true,
          erro: '',
          resultado: null
        })
      )
    ),
    shareReplay({ bufferSize: 1, refCount: true })
  );

  constructor(
    private readonly alunoService: AlunoService
  ) {}

  irParaPagina(pagina: number): void {
    if (pagina < 1) {
      return;
    }

    this.paginaSelecionada$.next(pagina);
  }
}

8. Template usando async pipe

<input
  type="search"
  [formControl]="pesquisa"
  placeholder="Pesquisar aluno...">

<ng-container *ngIf="vm$ | async as vm">

  <p *ngIf="vm.carregando">
    Carregando...
  </p>

  <p *ngIf="vm.erro">
    {{ vm.erro }}
  </p>

  <ng-container *ngIf="vm.resultado as pagina">
    <ul>
      <li *ngFor="let aluno of pagina.dados">
        {{ aluno.nome }}
      </li>
    </ul>

    <button
      type="button"
      (click)="irParaPagina(pagina.pagina - 1)"
      [disabled]="pagina.pagina <= 1 || vm.carregando">
      Anterior
    </button>

    <span>
      Página {{ pagina.pagina }} de {{ pagina.totalPaginas }}
    </span>

    <button
      type="button"
      (click)="irParaPagina(pagina.pagina + 1)"
      [disabled]="pagina.pagina >= pagina.totalPaginas || vm.carregando">
      Próxima
    </button>
  </ng-container>

</ng-container>

Como o template usa async, o Angular gerencia a inscrição desse fluxo. Não precisamos criar um subscribe() manual apenas para preencher propriedades da tela.

9. Evite consultar termos muito curtos

Se a pesquisa só deve ocorrer a partir de três caracteres, faça isso antes do HTTP:

switchMap(parametros => {
  if (parametros.termo.length > 0 &&
      parametros.termo.length < 3) {
    return of({
      carregando: false,
      erro: '',
      resultado: {
        dados: [],
        pagina: 1,
        porPagina: parametros.porPagina,
        total: 0,
        totalPaginas: 0
      }
    });
  }

  return this.alunoService.buscar(parametros).pipe(
    // ...
  );
})

Se termo vazio significar “listar todos”, mantenha a chamada. Se significar “não pesquisar”, trate-o da mesma forma.

10. E se houver outros filtros?

Use a mesma ideia: cada alteração vira uma ação que atualiza um único estado. Se mudar série, situação ou escola, você pode resetar a página no mesmo scan().

// ideia do estado final
{
  termo: 'daniel',
  escolaId: 12,
  situacao: 'ATIVO',
  pagina: 1,
  porPagina: 20
}

Assim a API sempre recebe uma fotografia consistente dos filtros atuais.

Erros comuns

  • fazer subscribe() dentro do service;
  • usar mergeMap em uma busca em que apenas o resultado mais recente interessa;
  • resetar a página emitindo um segundo evento e gerar chamadas duplicadas;
  • usar apenas debounceTime e deixar requisições antigas concorrendo;
  • controlar loading com um boolean global que pode sofrer corrida entre requisições;
  • não usar distinctUntilChanged() no termo;
  • enviar para a API um filtro diferente do estado exibido na tela.

Continue o cluster Angular

Resumo

FormControl.valueChanges
  → debounceTime
  → distinctUntilChanged
  → ação de termo
                   ↘
                    scan → estado único → switchMap → HttpClient
                   ↗                         ↓
clique da página → ação                 loading/erro/resultado

O ganho não é apenas cancelar HTTP. O principal é ter um único estado de pesquisa, que mantém termo e página sincronizados e produz uma única requisição para cada mudança real.