Construyendo un Data Grid con TanStack Table
Crea una cuadrícula de datos con TanStack Table y añade ordenación, filtros, paginación y virtualización, además de corregir bugs de memoización.
TanStack Table es una librería headless: gestiona la lógica de la tabla (ordenamiento, filtrado, paginación y modelos de filas) mientras tú conservas el 100% del marcado y los estilos.
Si alguna vez has pasado una tarde peleando con la hoja de estilos de un grid preconstruido solo para ajustar un borde, esta propuesta tendrá sentido de inmediato. Obtienes un motor de estado battle-tested y un lienzo en blanco para el DOM, que es exactamente lo que necesitas cuando el sistema de diseño es tuyo.
Esta guía conecta un data grid real sobre la versión estable actual, @tanstack/react-table v8: una tabla única que ordena, filtra globalmente y pagina. Luego aborda los dos problemas de escalabilidad que realmente duelen en producción: el bug de memoización que provoca re-renders infinitos y la virtualización para miles de filas.
Puntos clave
- En TanStack Table v8, renderiza cada encabezado y celda con
flexRender:flexRender(header.column.columnDef.header, header.getContext())para encabezados yflexRender(cell.column.columnDef.cell, cell.getContext())para celdas. Las llamadascell.render('Cell')ycolumn.render('Header')de la era v7 ya no existen. - El ordenamiento, el filtrado y la paginación son row models opcionales: pasa
getSortedRowModel(),getFilteredRowModel()ygetPaginationRowModel()en una sola llamada auseReactTabley el orden ya no importa. - El bug más común en TanStack Table es un bucle de re-renders infinitos causado por pasar un array
dataocolumnsnuevo en cada render. Envuelve ambos enuseMemoo defínelos en el ámbito del módulo. - Cuando superas unos pocos miles de filas en el cliente, deja de paginar y virtualiza con
useVirtualizerde@tanstack/react-virtualsobretable.getRowModel().rows. - La versión estable es v8, actualmente 8.21.3 en el tag
latestde npm. La v9 entró en beta en junio de 2026, así que mantén las tablas en producción en v8.
Qué significa “headless” y por qué importa
Headless significa que la librería entrega comportamiento, no interfaz de usuario. TanStack Table calcula los modelos de filas y expone estado y manejadores, pero no coloca nada en el DOM: v8 dejó de incluir estilos predeterminados y atributos role para que el núcleo pudiera mantenerse agnóstico al framework. La ventaja es el control total. La responsabilidad es que tú también gestionas la semántica, por lo que <table>, <th scope> y cualquier rol ARIA son tu responsabilidad.
En la práctica, esto significa que los estilos son de tu incumbencia: clases utilitarias de Tailwind, CSS Modules o styled components sobre tu propio marcado <table>. La librería nunca los toca.
Configurar un grid básico con TanStack Table
Discover how at OpenReplay.com.
Instala el paquete y construye la tabla con el hook useReactTable. Define las columnas con createColumnHelper para la inferencia de tipos, pasa tus data y columns, y registra getCoreRowModel(), el modelo de filas base que mapea tus datos en filas.
npm install @tanstack/react-table
El adaptador es compatible con todas las versiones de React desde la 16.8 hasta la 19, con la salvedad de que puede no comportarse correctamente con el React Compiler que se incluye en React 19.
import {
createColumnHelper,
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table'
type User = { firstName: string; lastName: string; age: number }
const columnHelper = createColumnHelper<User>()
const columns = [
columnHelper.accessor('firstName', { header: 'First Name' }),
columnHelper.accessor('lastName', { header: 'Last Name' }),
columnHelper.accessor('age', { header: 'Age' }),
]
Renderiza recorriendo getHeaderGroups(), getRowModel().rows y los getVisibleCells() de cada fila. Pasa la definición de columna y su contexto a flexRender para que los encabezados de texto, JSX y celdas de componentes se resuelvan correctamente. Según la guía de migración de v8, flexRender reemplaza las llamadas eliminadas cell.render('Cell') / column.render('Header').
function DataGrid({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
Agregar ordenamiento
Habilita el ordenamiento añadiendo getSortedRowModel(), manteniendo el estado sorting con onSortingChange, y conectando cada encabezado a header.column.getToggleSortingHandler(). Usa header.column.getIsSorted() (que devuelve 'asc', 'desc' o false) para mostrar el indicador de dirección.
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
<th onClick={header.column.getToggleSortingHandler()} style={{ cursor: 'pointer' }}>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
</th>
Filtrado y búsqueda global
La búsqueda global es un único fragmento de estado: agrega getFilteredRowModel(), mantén una cadena globalFilter en state y actualízala con onGlobalFilterChange. El filtro global predeterminado busca subcadenas en todas las columnas.
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
// <input value={globalFilter} onChange={e => setGlobalFilter(e.target.value)} />
Los filtros por columna utilizan el mismo row model, controlados por column.getFilterValue() y column.setFilterValue() desde un input en el encabezado. La coincidencia difusa (fuzzy matching) está disponible, pero requiere el helper externo @tanstack/match-sorter-utils como filterFn personalizado. Recurre a él solo cuando la búsqueda por subcadena no sea suficiente.
Paginación
Agrega getPaginationRowModel() y contrólalo con el estado pageIndex/pageSize; luego condiciona tus botones Siguiente/Anterior con table.getCanNextPage() y table.getCanPreviousPage(). El orden de registro de los row models no importa en v8, por lo que se combinan libremente junto con el ordenamiento y el filtrado en la misma llamada al hook.
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter, pagination },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
Previous
</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</button>
Esto constituye un grid completo y funcional: una sola llamada a useReactTable que ordena, filtra y pagina de forma conjunta.
Escalabilidad: el problema de memoización, la virtualización y las celdas personalizadas
El bug más común en TanStack Table es un bucle de re-renders infinitos causado por pasar un array data o columns nuevo en cada render. Declarar const columns = [...] o const data = [...] dentro del cuerpo del componente crea una nueva referencia en cada pasada; TanStack Table detecta el cambio de identidad, recalcula los row models, desencadena un re-render y el ciclo se repite. Envuelve ambos en useMemo (o defínelos en el ámbito del módulo, como en los ejemplos anteriores) para que sus referencias permanezcan estables.
const columns = useMemo(() => [/* ... */], [])
const data = useMemo(() => fetchedRows, [fetchedRows])
¿Paginar o virtualizar? Pagina cuando los tamaños de página están acotados o los datos se obtienen por página desde un servidor. Virtualiza cuando manejas miles de filas en el cliente dentro de una única vista de desplazamiento continuo. En ese caso, renderiza solo las filas visibles con @tanstack/react-virtual y su hook useVirtualizer sobre table.getRowModel().rows. Ten en cuenta que el hook actual de la v3 es useVirtualizer, no el antiguo useVirtual.
import { useVirtualizer } from '@tanstack/react-virtual'
const rows = table.getRowModel().rows
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 10,
})
Luego renderizas rowVirtualizer.getVirtualItems() dentro de un espaciador dimensionado por getTotalSize(), tal como muestran la documentación de TanStack Virtual. Este es el tipo de fallo que el session replay es especialmente bueno en detectar: un grid de 10 000 filas sin virtualización puede funcionar bien con un dataset de prueba en local, pero reproducir a un usuario real ordenando o haciendo scroll rápido en producción es donde emergen los fotogramas perdidos y el scroll jank.
Las celdas personalizadas son simplemente el renderer cell de la columna: devuelve un badge de estado, una fecha formateada o un input editable, y la librería no interfiere porque nunca renderiza UI por sí misma. El anclaje de columnas (column pinning), el redimensionamiento y el reordenamiento también están soportados a través de sus propias APIs de estado.
Conclusión
Ahora tienes un grid que ordena, filtra y pagina en una sola llamada a useReactTable, más las dos técnicas que lo mantienen rápido: referencias estables de data/columns y virtualización cuando superas unos pocos miles de filas. Desarrolla con v8 hoy, ya que la v9 lleva en beta desde junio de 2026, y recurre a useVirtualizer en el momento en que tu número de filas supere el tamaño de una página.
Preguntas frecuentes
¿Por qué mi TanStack Table está haciendo re-renders infinitos?
Un bucle de re-renders infinitos casi siempre proviene de pasar un array data o columns nuevo en cada render. Declarar const columns = [] o const data = [] dentro del cuerpo del componente crea una nueva referencia en cada pasada, por lo que TanStack Table detecta el cambio de identidad, recalcula sus row models y desencadena otro render. Envuelve ambos en useMemo o defínelos en el ámbito del módulo para que sus referencias permanezcan estables.
¿Debería usar TanStack Table v8 o v9?
Usa v8 para producción. La versión estable es v8 (8.21.3 en el tag latest de npm), mientras que v9 lleva en beta desde junio de 2026 y solo se publica como builds de prelanzamiento bajo el tag beta de npm. La v8 es compatible con React 16.8, 17, 18 y 19. Mantén las tablas en producción en v8 y experimenta con v9 únicamente en código no crítico hasta que alcance una versión estable.
¿Cuándo debo paginar versus virtualizar una TanStack Table?
Pagina cuando los tamaños de página están acotados o los datos se obtienen por página desde un servidor, usando getPaginationRowModel y el estado pageIndex/pageSize. Virtualiza cuando manejas miles de filas en el cliente en una única vista de desplazamiento continuo: renderiza solo las filas visibles con useVirtualizer de @tanstack/react-virtual sobre table.getRowModel().rows. Ambas técnicas no son mutuamente excluyentes, pero cuando superas unos pocos miles de filas en el cliente en una sola área de scroll, la virtualización es la opción correcta.
¿TanStack Table proporciona estilos predeterminados o roles de accesibilidad?
No. TanStack Table v8 es completamente headless. No genera marcado, estilos ni atributos role; la reescritura de v8 eliminó todo eso para que el núcleo pudiera funcionar con cualquier framework. Tú eres dueño del DOM completo, por lo que el marcado semántico como table, th con scope y cualquier rol ARIA son tu responsabilidad. Aplica estilos con Tailwind, CSS Modules o styled components sobre tu propio marcado.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k