Artigo original escrito por Shubham Khatri
Artigo original: How to Build a Custom Pagination Component in React
Traduzido e adaptado por Daniel Rosa

Geralmente, trabalhamos com aplicações da web que necessitam buscar grandes quantidades de dados de um servidor por meio de APIs e renderizá-los na tela.

Por exemplo, em uma aplicação de mídia social, fazemos o fetch das publicações e comentários dos usuários e os renderizamos. Em um painel de HR, exibimos informações sobre os candidatos que se inscrevem para uma vaga de emprego. Em um client de e-mail, mostramos os e-mails do usuário.

Renderizar todos os dados de uma vez na tela pode tornar sua página da web consideravelmente lenta, devido ao grande número de elementos do DOM  presentes na página.

Se queremos otimizar o desempenho, podemos adotar várias técnicas para renderizar os dados de modo mais eficaz. Alguns desses métodos incluem a rolagem infinita com virtualização e a paginação.

A paginação funciona bem quando você sabe o tamanho dos dados anteriormente e não deseja fazer inclusões ou exclusões ao conjunto de dados com frequência.

Por exemplo, em um site de mídias sociais, onde novas publicações são feitas a cada quantidade mínima de milissegundos, a paginação não seria uma solução ideal. Ela, no entanto, funciona bem com um painel de HR, onde as inscrições a vagas dos candidatos são exibidas e precisam ser filtradas ou ordenadas.

Neste artigo, nos concentraremos na paginação e criaremos um componente controlado que trata dos botões de páginas com base na página atual e na contagem total.

Também escreveremos um hook do React personalizado que nos dá um intervalo de números a serem renderizados pelo componente de paginação. Podemos usar esse hook de modo independente, bem como quando queremos renderizar um componente de paginação com estilos ou um design diferente.

Abaixo temos uma demonstração daquilo que criaremos neste tutorial:

PaginationDemo

Como configurar o projeto

Se você está familiarizado com a configuração de um projeto do React, você pode pular esta seção.

Para configurar nosso projeto do React, usaremos o pacote de linha de comando create-react-app. Você pode instalar o pacote globalmente usando npm install -g create-react-app ou yarn add global create-react-app.

Execute create-react-app a partir da linha de comando para criar um novo projeto assim:

npx create-react-app react-pagination

Em seguida, precisamos instalar nossas dependências. Usaremos apenas uma dependência adicional simples, chamada classnames, que fornece flexibilidade ao lidar com diversos classNames de modo condicional.

Para instalar essa dependência, execute npm install classnames ou yarn add classnames .

Agora, podemos rodar nosso projeto usando comando abaixo:

yarn start

Como definir a interface

Agora que temos nosso projeto em execução, vamos direto para o nosso componente Pagination.

Primeiro, vamos ver quais valores precisamos como props em nosso componente Pagination:

  • totalCount: representa a contagem total de dados disponíveis da fonte.
  • currentPage: representa a página ativa atual. Usaremos um índice de base 1 em vez do tradicional índice de base 0 para nosso valor de currentPage.
  • pageSize: representa os dados máximos visíveis em uma única página.
  • onPageChange: função de callback invocada com o valor da página atualizada quando a página é alterada.
  • siblingCount (opcional): representa o número mínimo de botões de página a serem mostrados em cada lado do botão de página atual. O padrão é 1.
image-85
Ilustração dos valores diferentes de siblingCount
  • className (opcional): className a ser adicionado ao contêiner de nível superior.

A partir do componente de paginação, invocaremos o hook usePagination, que receberá os seguintes parâmetros para calcular os intervalos de página: totalCount, currentPage , pageSize e siblingCount.

Como implementar o hook usePagination

Abaixo temos algumas coisas que precisamos considerar ao implementar o hook usePagination:

  • Nosso hook de paginação deve retornar o intervalo de números a serem exibidos em nosso componente de paginação como um array.
  • A lógica computacional precisa executar novamente quando currentPage, pageSize, siblingCount ou totalCount mudar.
  • O número total de itens retornado pelo hook deve permanecer constante. Isso evitará o redimensionamento do nosso componente de paginação se o tamanho do array do intervalo se altera quando o usuário está interagindo com o componente.

Levando em conta os itens acima, vamos criar um arquivo chamado usePagination.js em nossa pasta src do projeto e começar a implementação.

O esqueleto do nosso código será o seguinte:

export const usePagination = ({
  totalCount,
  pageSize,
  siblingCount = 1,
  currentPage
}) => {
  const paginationRange = useMemo(() => {
     // Nossa lógica de implementação vai aqui 
      
  }, [totalCount, pageSize, siblingCount, currentPage]);

  return paginationRange;
};

Se olharmos para o código acima, veremos que estamos usando o hook useMemo para calcular nossa lógica central. A função de callback useMemo será executada quando algum valor em seu array de dependência for alterado.

Além disso, estamos definindo o defaultValue de nosso prop siblingCount como 1, por ser um prop opcional.

Antes de seguirmos e implementarmos nossa lógica central, vamos entender os diferentes comportamentos do componente Pagination. A imagem abaixo contém os possíveis estados (states) de um componente de paginação:

image-80-1
Estados diferentes de um componente de paginação

Observe que há quatro estados possíveis para um componente de paginação. Examinaremos um por um.

  • A contagem total de páginas é inferior às amostras de página que queremos mostrar. Neste caso, retornaremos apenas o intervalo de 1 a totalPageCount.
  • A contagem total de páginas é maior que as amostras de página, mas somente os PONTOS à direita estão visíveis.
  • A contagem total de páginas é maior que as amostras de página, mas somente os PONTOS à esquerda estão visíveis.
  • A contagem total de páginas é maior que as amostras de página e os PONTOS da esquerda e da direita estão visíveis.

Como um primeiro passo, falaremos sobre o cálculo do total de páginas de totalCount e pageSize, conforme segue:

const totalPageCount = Math.ceil(totalCount / pageSize);

Observe que estamos usando Math.ceil para arredondar o número para o próximo valor inteiro maior. Isso garante que estamos reservando uma página a mais para os dados remanescentes.

Em seguida, seguiremos e implementaremos uma função range personalizada, que recebe um valor de start e end (início e fim) e retorna um array com elementos do início ao fim:

const range = (start, end) => {
  let length = end - start + 1;
  /*
  	Cria um array de determinado tamanho e define os elementos dentro do valor de início (start) e de fim (end).
  */
  return Array.from({ length }, (_, idx) => idx + start);
};

Por fim, implementaremos a lógica central mantendo em mente os casos acima.

export const usePagination = ({
  totalCount,
  pageSize,
  siblingCount = 1,
  currentPage
}) => {
  const paginationRange = useMemo(() => {
    const totalPageCount = Math.ceil(totalCount / pageSize);

    // A contagem de páginas é determinada com siblingCount + firstPage + lastPage + currentPage + 2*DOTS
    const totalPageNumbers = siblingCount + 5;

    /*
      Caso 1:
      Se o número de páginas for inferior aos números das páginas que queremos exibir em nosso paginationComponent, retornamos o intervalo [1..totalPageCount]
    */
    if (totalPageNumbers >= totalPageCount) {
      return range(1, totalPageCount);
    }
	
    /*
    	Calcular o índice irmão à esquerda e à direita e garantir que eles estão dentro do intervalo entre 1 e totalPageCount
    */
    const leftSiblingIndex = Math.max(currentPage - siblingCount, 1);
    const rightSiblingIndex = Math.min(
      currentPage + siblingCount,
      totalPageCount
    );

    /*
      Não mostramos pontos apenas quando há apenas um número de página a ser inseridos entre os extremos dos irmãos e os limites das páginas, ou seja, 1 e totalPageCount. Assim, estamos usando leftSiblingIndex > 2 e rightSiblingIndex < totalPageCount - 2
    */
    const shouldShowLeftDots = leftSiblingIndex > 2;
    const shouldShowRightDots = rightSiblingIndex < totalPageCount - 2;

    const firstPageIndex = 1;
    const lastPageIndex = totalPageCount;

    /*
    	Caso 2: Não há pontos à esquerda para serem exibidos, mas pontos à direita para exibir
    */
    if (!shouldShowLeftDots && shouldShowRightDots) {
      let leftItemCount = 3 + 2 * siblingCount;
      let leftRange = range(1, leftItemCount);

      return [...leftRange, DOTS, totalPageCount];
    }

    /*
    	Caso 3: Não há pontos à direita para serem exibidos, mas pontos à esquerda para exibir
    */
    if (shouldShowLeftDots && !shouldShowRightDots) {
      
      let rightItemCount = 3 + 2 * siblingCount;
      let rightRange = range(
        totalPageCount - rightItemCount + 1,
        totalPageCount
      );
      return [firstPageIndex, DOTS, ...rightRange];
    }
     
    /*
    	Caso 4: Pontos à esquerda e à direita para exibir
    */
    if (shouldShowLeftDots && shouldShowRightDots) {
      let middleRange = range(leftSiblingIndex, rightSiblingIndex);
      return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex];
    }
  }, [totalCount, pageSize, siblingCount, currentPage]);

  return paginationRange;
};
Implementação do hook personalizado usePagination

A ideia da implementação é a de que identificamos o intervalo de números que queremos mostrar em nosso componente de paginação e, em seguida, unimos estes aos separadores ou PONTOS (DOTS) quando retornamos o intervalo final .

Para o primeiro cenário, onde nosso totalPageCount é inferior ao número total de amostras que calculamos com base nos outros parâmetros, retornamos apenas um intervalo de números 1..totalPageCount .

Para os outros cenários, identificamos se precisamos de PONTOS (DOTS) no lado esquerdo ou no lado direito de currentPage calculando os índices da esquerda e da direita após incluírem as amostras irmãs a currentPage e, então, tomar nossas decisões.

Ao sabermos onde queremos mostrar os PONTOS, o resto dos cálculos é bastante direto.

Como implementar o componente de paginação

Como mencionei anteriormente, usaremos o hook usePagination em nosso componente de paginação e mapearemos o intervalo retornado para renderizá-lo.

Criamos o arquivo Pagination.js em nossa pasta src e implementamos a lógica do código conforme segue:

import React from 'react';
import classnames from 'classnames';
import { usePagination, DOTS } from './usePagination';
import './pagination.scss';
const Pagination = props => {
  const {
    onPageChange,
    totalCount,
    siblingCount = 1,
    currentPage,
    pageSize,
    className
  } = props;

  const paginationRange = usePagination({
    currentPage,
    totalCount,
    siblingCount,
    pageSize
  });

  // Se houver menos que 2 vezes no intervalo de paginação, não renderizamos o componente
  if (currentPage === 0 || paginationRange.length < 2) {
    return null;
  }

  const onNext = () => {
    onPageChange(currentPage + 1);
  };

  const onPrevious = () => {
    onPageChange(currentPage - 1);
  };

  let lastPage = paginationRange[paginationRange.length - 1];
  return (
    <ul
      className={classnames('pagination-container', { [className]: className })}
    >
       {/* Seta de navegação da esquerda */}
      <li
        className={classnames('pagination-item', {
          disabled: currentPage === 1
        })}
        onClick={onPrevious}
      >
        <div className="arrow left" />
      </li>
      {paginationRange.map(pageNumber => {
         
        // Se o pageItem for um PONTO (DOT), renderize o caractere unicode DOTS
        if (pageNumber === DOTS) {
          return <li className="pagination-item dots">&#8230;</li>;
        }
		
        // Renderize a amostra de página
        return (
          <li
            className={classnames('pagination-item', {
              selected: pageNumber === currentPage
            })}
            onClick={() => onPageChange(pageNumber)}
          >
            {pageNumber}
          </li>
        );
      })}
      {/*  Seta de navegação da direita */}
      <li
        className={classnames('pagination-item', {
          disabled: currentPage === lastPage
        })}
        onClick={onNext}
      >
        <div className="arrow right" />
      </li>
    </ul>
  );
};

export default Pagination;
Implementação da paginação

Não renderizamos um componente Pagination se há menos de duas páginas (e retornamos null) .

Renderizamos o componente Pagination como uma lista com setas à esquerda e à direita, que tratam das ações anterior e próxima tomadas pelo usuário. Entre as setas, mapeamos o paginationRange e renderizamos os números de página como pagination-items. Se o item de página é um ponto (DOT), renderizamos um caractere unicode para ele.

Como um tratamento especial, adicionamos uma classe disabled à seta da esquerda/direita se currentPage é a primeira ou a última página, respectivamente. Desativamos os pointer-events e atualizamos os estilos dos ícones de seta por meio de CSS se o ícone precisa ser desativado.

Também adicionamos tratamentos de evento de clique para as amostras de página, que invocará a função de callback onPageChanged com o valor atualizado de currentPage.

Nosso arquivo CSS conterá os seguintes estilos:

.pagination-container {
  display: flex;
  list-style-type: none;

  .pagination-item {
    padding: 0 12px;
    height: 32px;
    text-align: center;
    margin: auto 4px;
    color: rgba(0, 0, 0, 0.87);
    display: flex;
    box-sizing: border-box;
    align-items: center;
    letter-spacing: 0.01071em;
    border-radius: 16px;
    line-height: 1.43;
    font-size: 13px;
    min-width: 32px;

    &.dots:hover {
      background-color: transparent;
      cursor: default;
    }
    &:hover {
      background-color: rgba(0, 0, 0, 0.04);
      cursor: pointer;
    }

    &.selected {
      background-color: rgba(0, 0, 0, 0.08);
    }

    .arrow {
      &::before {
        position: relative;
        /* top: 3pt; Remova essa linha do comentário para baixar os ícones conforme solicitado nos comentários*/
        content: '';
        /* Usando uma escala em, as setas terão o tamanho acompanhando a fonte */
        display: inline-block;
        width: 0.4em;
        height: 0.4em;
        border-right: 0.12em solid rgba(0, 0, 0, 0.87);
        border-top: 0.12em solid rgba(0, 0, 0, 0.87);
      }

      &.left {
        transform: rotate(-135deg) translate(-50%);
      }

      &.right {
        transform: rotate(45deg);
      }
    }

    &.disabled {
      pointer-events: none;

      .arrow::before {
        border-right: 0.12em solid rgba(0, 0, 0, 0.43);
        border-top: 0.12em solid rgba(0, 0, 0, 0.43);
      }

      &:hover {
        background-color: transparent;
        cursor: default;
      }
    }
  }
}
Estilos do componente de paginação

Era isso!

Nossa implementação de paginação genérica está pronta e podemos usá-la em qualquer lugar de nossa base de código.

Como usar o componente de paginação personalizado

Como última etapa, vamos incorporar esse componente em um pequeno exemplo.

Para o escopo desse artigo, vamos renderizar os dados estáticos na forma de uma tabela. Então, vamos lá e façamos isso primeiro:

import React from 'react';
import data from './data/mock-data.json';

export default function App() {
  return (
    <>
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>FIRST NAME</th>
            <th>LAST NAME</th>
            <th>EMAIL</th>
            <th>PHONE</th>
          </tr>
        </thead>
        <tbody>
          {data.map(item => {
            return (
              <tr>
                <td>{item.id}</td>
                <td>{item.first_name}</td>
                <td>{item.last_name}</td>
                <td>{item.email}</td>
                <td>{item.phone}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </>
  );
}

Neste ponto, nossa UI tem a seguinte aparência:

InfiniteTable-1

Agora, para incorporar o componente Pagination, precisamos de duas coisas.

  • Primeiro, mantemos um state currentPage.
  • Segundo, calculamos os dados a serem renderizados para uma determinada página e somente os mapeamos e os renderizamos.

Para os fins dessa demonstração, mantemos a constante PageSize e definimos seu valor como 10. Também podemos fornecer um seletor para o usuário para selecionar o pageSize desejado.

Ao fazermos mudanças, podemos renderizar nosso componente Pagination com os props apropriados.

Com essas alterações em mente, nosso código final terá a seguinte aparência:

import React, { useState, useMemo } from 'react';
import Pagination from '../Pagination';
import data from './data/mock-data.json';
import './style.scss';

let PageSize = 10;

export default function App() {
  const [currentPage, setCurrentPage] = useState(1);

  const currentTableData = useMemo(() => {
    const firstPageIndex = (currentPage - 1) * PageSize;
    const lastPageIndex = firstPageIndex + PageSize;
    return data.slice(firstPageIndex, lastPageIndex);
  }, [currentPage]);

  return (
    <>
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>FIRST NAME</th>
            <th>LAST NAME</th>
            <th>EMAIL</th>
            <th>PHONE</th>
          </tr>
        </thead>
        <tbody>
          {currentTableData.map(item => {
            return (
              <tr>
                <td>{item.id}</td>
                <td>{item.first_name}</td>
                <td>{item.last_name}</td>
                <td>{item.email}</td>
                <td>{item.phone}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
      <Pagination
        className="pagination-bar"
        currentPage={currentPage}
        totalCount={data.length}
        pageSize={PageSize}
        onPageChange={page => setCurrentPage(page)}
      />
    </>
  );
}

Código final de demonstração

Aqui temos a demonstração ao vivo deste tutorial:

Conclusão

Neste artigo, criamos um hook do React usePagination personalizado e o usamos em nosso componente Pagination. Também implementamos uma demonstração curta que usa este componente.

Você pode conferir o código-fonte completo para este tutorial neste repositório do GitHub.

Se você tem perguntas ou sugestões relativas a este artigo, fique à vontade para entrar em contato com o autor pelo Twitter.

Obrigado pela leitura.