Filtro de tabela utilizando PipeTransform e DebounceTime - como filtrar os dados em tempo real - Angular 2+

Veja como filtrar os dados de uma tabela de forma dinâmica através da utilização de um PipeTransform e o operador debounceTime do RxJS.

Filtro de tabela utilizando PipeTransform e DebounceTime - como filtrar os dados em tempo real - Angular 2+

Construção da service para consultar os dados

Para construir a base de estudo, vamos utilizar uma lista de distritos do estado de São Paulo disponibilizadas no seguinte link pelo IBGE:

https://servicodados.ibge.gov.br/api/v1/localidades/estados/35/distritos.

Este link exibe diversas informações, mas vamos focar apenas na utilização das informações id e o nome do distrito. Deste modo nossa service ficará da seguinte forma:

// distritos.service.ts

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

const url =
    'https://servicodados.ibge.gov.br/api/v1/localidades/estados/35/distritos';

@Injectable({providedIn: 'root'})
export class DistritosService {
    constructor(private httpClient: HttpClient) { }

    listar(): Observable<{ id: number, nome: string }[]> {
        return this.httpClient
            .get<any[]>(url)
            .pipe(
                map(dadosDaApi => {
                    return dadosDaApi.map(a => {
                        return {
                            id: a.id,
                            nome: a.nome
                        };
                    });
                })
            );
    }
}

Apesar de não ser o foco do artigo, vale comentar sobre o map. Sua utilização tem o objetivo de receber os dados brutos da API mas retornar apenas um novo objeto do tipo Array<{ id: number, nome: string }>. A razão desta lógica é que vamos utilizar apenas estas duas informações por registro, id e nome.

Exibindo os dados na tabela

Com a service construída vamos focar na exibição dos dados na tela. Como vamos exibir os dados no AppComponent, certifique-se de importar o módulo HttpClientModule em seu AppModule conforme abaixo:

// app.module.ts

import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    AppRoutingModule,
    BrowserModule,
    CommonModule,
    HttpClientModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
// app.component.ts

import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';

import { DistritosService } from './services/distritos.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
  distritos: Array<{ id: number, nome: string }> = [];

  constructor(private distritosService: DistritosService) { }

  ngOnInit() {
    this.distritosService.listar().subscribe(
      retornoApi => this.distritos = retornoApi
    )
  }
}
<!-- app.component.html -->

<html>
  <head>
    <title>Testes</title>
  </head>
  <body>
    <table>
      <tr *ngFor="let distrito of distritos">
        <td>{{distrito.id}}</td>
        <td>{{distrito.nome}}</td>
      </tr>
    </table>
    <router-outlet></router-outlet>
  </body>
</html>

Após subir a aplicação, ng serve, podemos visualizar o resultado na tela do navegador:

Renderização de uma parte da tela do template que exibe os distritos de São Paulo

Filtrando os dados

Agora que os dados estão renderizados e a lista de distritos já está armazenada no AppComponent na variável distritos, vamos criar um campo de texto para que o usuário possa filtrar os dados na tela.

Primeiro vou mostrar a forma mais comum de se encontrar essa implementação, pelo menos no meu cotidiano de desenvolvedor, depois a forma correta de executar esta implementação.

Vamos adicionar um campo de texto para o usuário poder digitar o que ele está buscando. Vamos também adicionar um evento para poder disparar o mecanismo de filtro:

<!-- app.component.html -->

<!-- ... (código ocultado) ... -->

<input
  type="search"
  placeholder="busca..."
  (change)="filtrar($event.target.value)">
<table>
  <tr *ngFor="let distrito of distritos">
    <td>{{distrito.id}}</td>
    <td>{{distrito.nome}}</td>
  </tr>
</table>

<!-- ... (código ocultado) ... -->

Agora vamos criar o método filtrar para de fato filtrar os dados se baseando no que o usuário digitou no campo de busca:

// app.component.ts

// ... (código ocultado) ...

  filtrar(palavraChave: string) {
    if (palavraChave) {
      palavraChave = palavraChave.toUpperCase();

      this.distritos = this.distritos.filter(a =>
            a.nome.toUpperCase().indexOf(palavraChave) >= 0
        );
    }
  }

// ... (código ocultado) ...

Se você analisou o código acima e entendeu a lógica, provavelmente já percebeu que somente a primeira busca irá funcionar. Veja que após a primeira busca a variável distritos recebe o resultado filtrado e com isto perdemos a lista completa.

Veja a animação na imagem abaixo deste comportamento:

Para corrigir este comportamento teríamos que criar uma outra variável para guardar a lista completa de distritos e aumentar algumas linhas de código para controlar as duas listas (uma lista completa e outra filtrada). Vamos economizar linhas de código e utilizar um pipe para simplificar esta tarefa.

Se você não está acostumado com a criação de pipes, sugiro dar uma lida neste artigo: https://consolelog.com.br/como-construir-um-pipe-para-formatar-cpf-em-um-projeto-angular2/

Utilizando um pipe para filtrar os dados

O objetivo do nosso pipe é receber dois argumentos:

  1. lista completa de distritos
  2. palavra-chave que será utilizada para filtrar os dados - será o que o usuário digitar

O retorno será a lista de distritos filtrada. Nosso PipeTransform ficará no seguinte formato:

// array-filtro.pipe.ts

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
    name: 'arrayFiltro'
})
export class ArrayFiltroPipe implements PipeTransform {
    transform(value: Array<any>, filtro: string): any {
        if (filtro) {
            filtro = filtro.toUpperCase();
            
            return value.filter(a =>
                a.nome.toUpperCase().indexOf(filtro) >= 0
            );
        } else {
            // Quando filtro for vazio ou nulo,
            // retornamos o próprio array
            return value;
        }
    }
}

Para não nos esquecermos, vamos registrar este pipe no AppModule:

// app.module.ts

// ... (código ocultado) ...

import { ArrayFiltroPipe } from './array-filtro.pipe';

@NgModule({
  declarations: [
    AppComponent,
    ArrayFiltroPipe,  // <<< registro do ArrayFiltroPipe
  ],

// ... (código ocultado) ...

E por fim as alterações no AppComponent:

// app.component.ts

// ... (código ocultado) ...

import { Component, OnInit } from '@angular/core';

import { DistritosService } from './services/distritos.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
  distritos: Array<{ id: number, nome: string }> = [];
  filtro: string = ''; // << Variável que irá guardar
                       //    o que o usuário digitou

  constructor(private distritosService: DistritosService) { }

  ngOnInit() {
    this.distritosService.listar().subscribe(
      retornoApi => this.distritos = retornoApi
    )
  }
}

// ... (código ocultado) ...

<!-- app.component.html -->

<!-- ... (código ocultado) ... -->

<input
  type="search"
  placeholder="busca..."
  (keyup)="filtro = $event.target.value">
<table>
  <tr *ngFor="let distrito of (distritos | arrayFiltro : filtro)">
    <td>{{distrito.id}}</td>
    <td>{{distrito.nome}}</td>
  </tr>
</table>

<!-- ... (código ocultado) ... -->

Veja no template acima que a cada evento keyup modificamos o valor da variável filtro com o valor do <input>. Como a variável filtro é utilizada em nosso arrayFiltro (pipe), toda vez que ela (variável) tem seu valor alterado nosso pipe entra em ação para filtrar os dados:

Busca viva utilizando um PipeTransform

Utilizando o debounceTime para melhorar a experiência

Veja que na animação da imagem acima, assim que o usuário digita algum caracter o mecanismo de filtro é ativo. No nosso exemplo isto não é um problema porque a lista de dados já está em memória, porém, isto pode ser um grande problema se a cada ciclo de filtragem for necessário efetuar uma requisição para obter nossos dados ou mesmo se a lista carregada em memória for muito grande. Já imaginou se a cada tecla pressionada houver uma requisição a uma API?

Para melhorarmos este mecanismo, vamos ativar a lógica de filtragem apenas quando houver um intervalo maior que x milisegundos entre cada keyup. Normalmente este intervalo indica que o usuário digitou parcialmente ou completamente o que está buscando e aguarda uma atualização na tela.

Esta lógica pode ser um pouco complexa para ser implementada, mas graças ao operador debounceTime do RxJS não precisamos nos preocupar com esta implementação. Veja abaixo as alterações:

No template removemos o (keyup) e adicionamos o #campoBusca. Este #campoBusca serve para pegarmos a referência do <input> na classe através do @ViewChild:

<!-- app.component.html -->

<!-- ... (código ocultado) ... -->

<input
  #campoBusca
  type="search"
  placeholder="busca...">
<table>
  <tr *ngFor="let distrito of (distritos | arrayFiltro : filtro)">
    <td>{{distrito.id}}</td>
    <td>{{distrito.nome}}</td>
  </tr>
</table>

<!-- ... (código ocultado) ... -->

Na classe AppComponent adicionamos o evento (keyup) ao <input> utilizando o fromEvent do RxJS:

// app.component.ts

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit, AfterViewInit {
  distritos: Array<{ id: number, nome: string }> = [];
  filtro: string = '';

  @ViewChild('campoBusca') campoBusca: ElementRef<HTMLInputElement>;

  constructor(private distritosService: DistritosService) { }

  ngOnInit() {
    this.distritosService.listar().subscribe(
      retornoApi => this.distritos = retornoApi
    )
  }

  ngAfterViewInit() {
    fromEvent(this.campoBusca.nativeElement, 'keyup')
      .pipe(
        // Deixei um intervalo bem alto (2s)
        // para ficar bem claro na animação.
        debounceTime(2000)
      )
      .subscribe((e: Event) => {
        const target = e.target as HTMLInputElement;
        this.filtro = target.value;
      });
  }
}

Conforme comentado no código, deixamos o debounceTime com um valor bem alto para ressaltar seu efeito na tela, veja no gif abaixo:

Busca viva utilizando o PipeTransform em conjunto com o debounceTime do RxJS

Considerações

Veja que a utilização do PipeTransform tornou a implementação muito mais simples na filtragem dos dados. Um outro ponto importante foi a utilização do debounceTime para evitar processamento desnecessário, ou seja, esperamos o usuário digitar algo mais concreto para então ativarmos nosso fluxo de filtragem.

O conteúdo deste post pode e será muito útil para você que é desenvolvedor e usa o Angular 2+. Não deixe de se aprofundar no assunto e expandir seu conhecimento para outros operadores do RxJS.

Links interessantes: