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.
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
})
);
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
mergeMapem uma busca em que apenas o resultado mais recente interessa; - resetar a página emitindo um segundo evento e gerar chamadas duplicadas;
- usar apenas
debounceTimee 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
- Como aplicar debounce em pesquisa no Angular
- Como cancelar requisições HTTP anteriores com switchMap
- Paginação e filtros no Angular com API
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.