Durante un desarrollo, ¿cuántas veces hemos tenido que representar datos en formato tabla? Si tenemos muchos datos que presentar al usuario, esta es la manera más clara y organizada de hacerlo, pero es muy común que las tablas de la aplicación que estemos desarrollando necesiten incluir paginación, ordenación, filtrado, agrupación, o incluso drag & drop entre filas.

En ocasiones puede resultar tedioso programar desde cero dichas funcionalidades o carecemos del tiempo necesario para ello. Veamos cómo hacerlo.

¿Qué es React Table?

Se trata de una de las librerías más utilizadas para la creación y gestión de tablas con React, que cuenta con casi 14.000 estrellas en GitHub en el momento de escribir este artículo.

Ha sido desarrollada por Tanner Linsley, el autor de React Query, la famosa librería para fetching y cacheo de datos y de la cual ya hemos hablado en otro artículo.

Está compuesta por una colección de hooks para potenciar nuestras tablas de una manera sencilla y declarativa, definiéndose a sí misma como Table Utility y no Table Component, dado que tiene por objetivo facilitar su integración en cualquier UI previamente definida.

En cuanto a sus características, se nos presenta como una solución:

Instalación

Podemos instalar React Table mediante los gestores de paquetes NPM o Yarn.

npm install react-table --save

yarn add react-table

Definiendo el contenido

En primer lugar, vamos a definir los datos que gestionará nuestra tabla. Para ello, crearemos dos custom hooks que nos facilitarán la información que necesitamos, uno para la cabecera de la tabla o columnas, y otro para el contenido o filas.

import { useMemo } from "react";

export default function useColumns() {
 const columns = useMemo(
   () => [
     {
       Header: "Marca",
       accessor: "marca"
     },
     {
       Header: "Modelo",
       accessor: "modelo"
     },
     {
       Header: "Segmento",
       accessor: "segmento"
     },
     {
       Header: "Año",
       accessor: "anio"
     }
   ],
   []
 );

 return columns;
}
import { useMemo } from "react";

export default function useRows() {
 const rows = useMemo(
   () => [
     {
       marca: "Audi",
       modelo: "A3",
       segmento: "Sedan, Convertible",
       anio: "2015"
     },
     {
       marca: "Audi",
       modelo: "A3",
       segmento: "Wagon",
       anio: "2013"
     },
     {
       marca: "Audi",
       modelo: "A3 Sportback e-tron",
       segmento: "Wagon",
       anio: "2016"
     },
     {
       marca: "Audi",
       modelo: "A4",
       segmento: "Sedan, Convertible",
       anio: "2006"
     },
     {
       marca: "Audi",
       modelo: "A4",
       segmento: "Sedan, Wagon",
       anio: "2001"
     },
     {
       marca: "Audi",
       modelo: "A4 allroad",
       segmento: "Wagon",
       anio: "2019"
     },
     {
       marca: "Audi",
       modelo: "A5",
       segmento: "Coupe",
       anio: "2008"
     },
     {
       marca: "Audi",
       modelo: "A5 Sport",
       segmento: "Convertible, Coupe",
       anio: "2017"
     },
     {
       marca: "Audi",
       modelo: "Q3",
       segmento: "SUV",
       anio: "2020"
     },
     {
       marca: "Audi",
       modelo: "R8",
       segmento: "Coupe",
       anio: "2008"
     },
     {
       marca: "Audi",
       modelo: "TT",
       segmento: "Coupe",
       anio: "2019"
     },
     {
       marca: "Audi",
       modelo: "Q7",
       segmento: "SUV",
       anio: "2015"
     },
     {
       marca: "Audi",
       modelo: "Q8",
       segmento: "SUV",
       anio: "2019"
     },
     {
       marca: "Audi",
       modelo: "Cabriolet",
       segmento: "Convertible, Coupe",
       anio: "1996"
     }
   ],
   []
 );

 return rows;
}

Es importante que, para devolver los datos, hagamos uso del hook useMemo. Con esto evitamos renderizados innecesarios de nuestra tabla y renderizamos únicamente cuando alguno de los valores cambie.

Creando nuestra tabla

React Table nos lo pone muy fácil para crear nuestra tabla. Para ello, nos provee el hook useTable, al cual debemos informar con los datos de nuestras columnas y filas para generar una nueva instancia de la que obtendremos toda la información de la API.

import { useTable } from "react-table";
import useRows from "./hooks/useRows";
import useColumns from "./hooks/useColumns";

const columns = useColumns();
const data = useRows();
const table = useTable({ columns, data });

A partir de esta nueva instancia, ya podemos hacer uso de nuevas propiedades para ir gestionando nuestra tabla HTML.

const {
   getTableProps,
   getTableBodyProps,
   headerGroups,
   rows,
   prepareRow
 } = table;

A continuación, voy a utilizar el propio código para mostrar dónde debemos utilizar cada una de las propiedades.

Recordad que el objetivo de esta librería es potenciar nuestras propias tablas y no facilitarnos un nuevo componente Table cuya lógica interna desconocemos y que trae consigo estilos propios que tenemos que adaptar a nuestra interfaz.

{/* Añadimos las propiedades a nuestra tabla nativa */}
     <table {...getTableProps()}>
       <thead>
         {
           // Recorremos las columnas que previamente definimos
           headerGroups.map(headerGroup => (
             // Añadimos las propiedades al conjunto de columnas
             <tr {...headerGroup.getHeaderGroupProps()}>
               {
                 // Recorremos cada columna del conjunto para acceder a su información
                 headerGroup.headers.map((column) => (
                   // Añadimos las propiedades a cada celda de la cabecera
                   <th {...column.getHeaderProps()}>
                     {
                       // Pintamos el título de nuestra columna (propiedad "Header")
                       column.render("Header")
                     }
                   </th>
                 ))
               }
             </tr>
           ))
         }
       </thead>
{/* Añadimos las propiedades al cuerpo de la tabla */}
       <tbody {...getTableBodyProps()}>
         {
           // Recorremos las filas
           rows.map(row => {
             // Llamamos a la función que prepara la fila previo renderizado
             prepareRow(row);
             return (
               // Añadimos las propiedades a la fila
               <tr {...row.getRowProps()}>
                 {
                   // Recorremos cada celda de la fila
                   row.cells.map((cell) => {
                     // Añadimos las propiedades a cada celda de la fila
                     return (
                       <td {...cell.getCellProps()}>
                         {
                           // Pintamos el contenido de la celda
                           cell.render("Cell")
                         }
                       </td>
                     );
                   })
                 }
               </tr>
             );
           })
         }
       </tbody>
     </table>

¿Sencillo, no? Todo ello manteniendo nuestras etiquetas nativas de HTML. Recordad que esta librería es headless en cuanto a UI, lo que significa que si queremos algún estilo determinado, debemos incluirlo nosotros.

Este sería el resultado obtenido con un mínimo de estilos añadidos para facilitar la apariencia de las filas:

Estarás pensando que el resultado no deja de ser una tabla convencional, y que esto podrías hacerlo directamente rellenando las celdas a mano o recorriendo tus datos previamente definidos.

Tienes toda la razón, pero si te fijas, eso es casi exactamente lo que hemos hecho: hemos preservado al completo los elementos por defecto de una tabla html sin tener que añadir nuevos componentes que los reemplazaran.

Aun así, ahora vamos con lo verdaderamente interesante, que es añadir nuevas funcionalidades y potenciar nuestra tabla haciendo uso de los hooks que tenemos disponibles para ello. Podéis ver el ejemplo completo en este enlace.

Ordenación

Una de las funcionalidades más comunes a la hora de implementar tablas, es la posibilidad de ordenar los datos de manera ascendente/descendente clicando en cada una de las columnas que la componen.

Para ello, la librería nos provee del hook useSortBy para incluirlo directamente en la configuración inicial de la tabla y así indicar que queremos dicha funcionalidad.

import { useTable, useSortBy } from "react-table";
const table = useTable({ columns, data }, useSortBy);

Tras indicar que queremos disponer de la ordenación, debemos añadir la función de ordenado al elemento que contenga el título de nuestras columnas.

Con ello, las columnas aplicarán la ordenación a cada clic que hagamos sobre su título y podremos acceder a los dos estados isSorted e isSortedDesc que nos informarán mediante un booleano de la ordenación aplicada.

<th {...column.getHeaderProps(column.getSortByToggleProps())}
   className={
                       column.isSorted
                         ? column.isSortedDesc
                           ? "desc"
                           : "asc"
                         : ""
                     }
                   >
                     {column.render("Header")}
                   </th>

En el ejemplo anterior, añadimos una nueva clase CSS desc o asc en función del estado activo en ese momento para poder darle algo de estilos y que lo veamos al clicar.

Con esto ya estaría, ¡hemos implementado un algoritmo de ordenación en ambos sentidos sobre todas las columnas de nuestra tabla en pocas líneas de código y de manera declarativa!

Podéis ver el ejemplo completo en este enlace.

Filtrado

Hay ocasiones en las que queremos dar la oportunidad al usuario de que pueda filtrar entre los datos que le estamos mostrando, para que pueda encontrar los valores que le interesen de una manera rápida y sin tener que obligar a hacer scroll sobre toda la tabla hasta dar con ellos.

Para este caso, la librería nos provee de dos hooks de filtrado para nuestra tabla:

Para el siguiente ejemplo vamos a implementar el filtro global, mediante el cual un usuario podría buscar un determinado coche basándose en cualquiera de los campos que lo componen.

Importamos el hook del filtrado global acompañado de useAsyncDebounce que explicaremos más adelante.

import { useTable, useGlobalFilter, useAsyncDebounce } from "react-table";
const table = useTable({ columns, data }, useGlobalFilter);

Habiendo indicado a nuestra tabla el hook que queremos utilizar, podemos extraer aquellas propiedades que nos hacen falta para implementar la función de filtrado al completo.

const {
   preGlobalFilteredRows,
   setGlobalFilter,
   state: { globalFilter }
 } = table;

Para hacer uso de estas propiedades, definiremos un nuevo componente llamado CarsFilter con la única responsabilidad de proporcionar el filtrado.

Lo situaremos encima de la tabla, y estará compuesto por un input en el que el usuario realizará su búsqueda y obtendrá nuevos resultados filtrados al momento.

function CarsFilter({ preGlobalFilteredRows, globalFilter, setGlobalFilter }) {
     const totalCarsAvailable = preGlobalFilteredRows.length;
     const [value, setValue] = useState(globalFilter);

 const onFilterChange = useAsyncDebounce(
   (value) => setGlobalFilter(value || undefined),
   200
 );

 const handleInputChange = (e) => {
   setValue(e.target.value);
   onFilterChange(e.target.value);
 };

 return (
   <span className="cars-filter">
     Encuentra tu coche favorito
     <input
       size={50}
       value={value || ""}
       onChange={handleInputChange}
       placeholder={`${totalCarsAvailable} modelos disponibles...`}
     />
   </span>
 );
}

Como podéis ver, el componente recibe las propiedades previamente mencionadas y almacena en un estado interno mediante useState el valor del filtro global, que inicialmente será nulo ya que el usuario no ha empezado a escribir todavía.

¿De qué manera se lleva a cabo el filtrado?

Conforme el usuario empiece a escribir para obtener sus datos filtrados, realizamos dos acciones simultáneas contenidas en la función handleInputChange:

  1. Actualizamos el valor del estado interno del componente para saber en todo momento qué está escribiendo el usuario.
  2. Haciendo uso del valor almacenado en el estado, actualizamos el valor del filtro global cada 2 milisegundos. Esto es posible gracias al uso del hook useAsyncDebounce que comentaba al empezar el ejemplo, el cual nos va a permitir actualizar de manera asíncrona el filtro global conforme el usuario vaya escribiendo, evitando así que se produzca retardo al mostrar los nuevos resultados.

Finalmente situamos el nuevo componente dentro del <thead> de la tabla, encima de nuestras columnas:

<thead>
         <tr>
           <th colSpan={4}>
             <CarsFilter
               preGlobalFilteredRows={preGlobalFilteredRows}
               globalFilter={globalFilter}
               setGlobalFilter={setGlobalFilter}
             />
           </th>
         </tr>
         {headerGroups.map((headerGroup) => (
           <tr {...headerGroup.getHeaderGroupProps()}>
             {headerGroup.headers.map((column) => (
     <th {...column.getHeaderProps()}>{column.render("Header")}</th>
             ))}
           </tr>
         ))}
       </thead>

Con todo ello, habríamos incorporado el filtrado teniendo en cuenta todas las columnas de la tabla: marca del coche, modelo, segmento y año de fabricación.

Si, por algún motivo, quisiéramos filtrar teniendo como referencia solo determinados campos de la tabla (como por ejemplo, el año de fabricación del coche), podríamos emplear el hook useFilters en lugar de useGlobalFilter. O incluso los dos a la vez, si quisiéramos incluir varios tipos de filtrado, ¡son totalmente compatibles!

Podéis ver el ejemplo completo en este enlace.

Paginación

De la misma manera que ocurre con el filtrado, a no ser que los datos que representemos en la tabla sean pocos, siempre nos van a requerir que nuestra tabla incluya paginación para que el usuario pueda navegar cómodamente por ella.

La paginación implica limitar la cantidad de datos que vemos en un primer momento para evitar sobrecargar la interfaz debido a tener que mostrar todas las filas.

De este modo, los datos se distribuyen en varios conjuntos (o páginas), haciendo posible la navegación sobre ellos por medio de botones que incorporen estas funciones.

Para incorporar esta funcionalidad a nuestra tabla, empezamos por importar el hook usePagination e incluirlo en la configuración de nuestro hook useTable como hemos hecho previamente en los otros ejemplos.

import { useTable, usePagination } from "react-table";
const table = useTable({
     columns,
     data,
     initialState: {
       pageSize: 5,
       pageIndex: 0
     }
   },
   usePagination
 );

La paginación necesita que indiquemos un estado inicial en el cual podamos indicar cuántas filas queremos mostrar por página, así como el índice de la página inicial.

Para este caso concreto, dado que tengo un total de 15 datos que se traducen en 15 filas, quisiera distribuirlos en páginas de 5 filas cada una, haciendo un total de 3 páginas.

En cuanto al índice, dejaremos el valor por defecto, ya que queremos que la primera página que vea el usuario concuerde con los primeros datos y no haya saltos.

Solamente con la configuración que acabamos de definir, nuestra tabla ya ha dejado de mostrar todos los datos de golpe para distribuirlos tal y como le hemos indicado: 5 filas por página dando un total de 3 páginas.

Es momento de habilitar la navegación por las distintas páginas haciendo uso de las propiedades que tenemos disponibles para ello. Las propiedades que debemos extraer de nuestro objeto table son las siguientes:


 const {
   page,
   canPreviousPage,
   canNextPage,
   pageOptions,
   pageCount,
   gotoPage,
   nextPage,
   previousPage,
   setPageSize,
   state: { pageIndex, pageSize }
 } = table;

Como podéis ver, es totalmente declarativo, y con solamente leer los nombres de las propiedades, podemos deducir cual es el fin de cada una.

  1. page - Array de filas para cada página determinado por nuestro índice pageIndex.
  2. canPreviousPage - Booleano que nos indica si podemos retroceder a un índice anterior.
  3. canNextPage - Booleano que nos indica si aún podemos avanzar a un índice superior.
  4. pageOptions - Array con cada página disponible.
  5. pageCount - Entero con la suma total de páginas.
  6. gotoPage - Función que nos permite setear el índice de la página a la cual queremos desplazarnos.
  7. nextPage - Función que incrementa uno a uno el índice en el que nos encontremos.
  8. previousPage - Función que reduce uno a uno el índice en el que nos encontremos.
  9. setPageSize - Función que establece el número de elementos que queramos mostrar por página.
  10. pageIndex - Entero que indica el índice por defecto que hayamos puesto en el estado inicial.
  11. pageSize - Entero que indica el tamaño de página que hayamos puesto en el estado inicial.

Haremos debajo de la tabla un nuevo <div> contenedor que incluya todos los botones necesarios para hacer posible la navegación e ir informando del estado actual de la paginación.

<div className="pagination">
       <span>
         Página
         <strong>
           {pageIndex + 1} de {pageOptions.length}
         </strong>
       </span>
       <div className="controls">
<button onClick={() => gotoPage(0)} disabled={!canPreviousPage}>
           <BiFirstPage className="page-controller" />
         </button>
         <button onClick={() => previousPage()} disabled={!canPreviousPage}>
           <MdKeyboardArrowLeft className="page-controller" />
         </button>
         <button onClick={() => nextPage()} disabled={!canNextPage}>
           <MdKeyboardArrowRight className="page-controller" />
         </button>
         <button onClick={() => gotoPage(pageCount - 1)} disabled={!canNextPage}>
           <BiLastPage className="page-controller" />
         </button>
       </div>
       <select value={pageSize} onChange={e => setPageSize(Number(e.target.value))}>
         {[5, 10, 15].map(pageSize => (
           <option key={pageSize} value={pageSize}>
             Mostrar {pageSize}
           </option>
         ))}
       </select>
     </div>

En el nuevo contenedor podemos distinguir tres partes:

  1. El indicador de la página en la que nos encontramos con respecto al total de ellas.
  2. El conjunto de botones que nos permiten navegar entre páginas individuales o bien acceder directamente al final por ambos sentidos.
  3. El desplegable para que el usuario pueda seleccionar el tamaño de la página y poder ver varios resultados sin tener que usar los botones de navegación para ello.

Con todo ello, habríamos terminado de incorporar la paginación a nuestra tabla por medio del hook usePagination.

Implementar esta funcionalidad por medio de propiedades y funciones directamente extraídas de los hooks de la librería, nos ha ahorrado tener que lidiar con estados internos en el componente para comprobar en todo momento en qué página nos encontramos, qué datos debemos mostrar, habilitar/deshabilitar las acciones de los botones en cada caso, etc.

¡Toda la magia nos la dan ya hecha!

Podéis ver el ejemplo completo en este enlace.

Conclusiones

En este artículo hemos podido ver un modo de desarrollar tablas ágilmente en nuestras aplicaciones React, con pocas líneas de código, de manera declarativa y sin romper con el diseño de nuestra interfaz, implicando un ahorro de tiempo al no tener que adaptar el look & feel de nuestra aplicación.

Al igual que con cualquier otra librería o dependencia que podamos incluir en nuestro proyecto, es necesario que, como desarrolladores, no nos dejemos llevar únicamente por el reducido tamaño que pueda tener esta, lo bien que se vea en su documentación o simplemente por tratar de cubrir la necesidad del momento sin pensar en el futuro.

Cada proyecto es distinto del anterior y debemos saber evaluar si delegar implementaciones de nuestro desarrollo a nuevas dependencias puede suponer un problema en el largo plazo.

Para el caso concreto de esta dependencia, sería conveniente en primer lugar, preguntarnos en cuántas partes de nuestra aplicación sería necesario aplicarla y en base a ello, determinar si puede ser una mejor opción hacer un componente genérico reutilizable con aquellas funcionalidades que más se repitan en nuestro proyecto.

Por ejemplo, si participáramos en el desarrollo de una aplicación dedicada a la investigación médica, que cuenta con numerosas tablas con infinidad de resultados y comparativas, quizá fuera más conveniente hacer un componente propio ResultsTable que incluyera paginación por defecto, ahorrando el tener que delegar esta funcionalidad en una dependencia de terceros y a su vez en las múltiples partes de nuestro código.

Recomendaría el uso de esta librería en cualquier proyecto de tamaño contenido en el que se sepa a priori a cuántas partes de la aplicación afectaría su incorporación y, tras evaluar, si tendríamos que recurrir a ella muchas o pocas veces en futuras fases del desarrollo.

Asimismo, lo aconsejaría encarecidamente para proyectos personales o pruebas de concepto que busquen un desarrollo ágil con vistas a desplegar cuanto antes una primera versión al público.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.