Tanstack table
Escopo: TypeScript. Guia baseado em TanStack Table 8.21, dentro do cenário de React SPA.
O TanStack Table monta a lógica da tabela e não desenha nada. Ele calcula a ordenação, a paginação, o filtro e a seleção de linha, e devolve o resultado para você renderizar com o seu próprio <table>. A marcação e o estilo continuam sendo seus, o que deixa a tabela combinar com o resto da interface em vez de trazer o visual da biblioteca junto.
A página cobre a definição das colunas, o que fica no cliente e o que vai para o servidor, e o momento em que a tabela precisa de virtualização.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| headless (sem interface própria) | A biblioteca entrega o comportamento e deixa a marcação e o estilo com você |
| column definition (definição de coluna) | O objeto que declara de onde vem o valor, o cabeçalho e como a célula é desenhada |
| accessor (leitor do campo) | A chave ou função que extrai da linha o valor daquela coluna |
| row model (modelo de linhas) | O resultado do processamento: as linhas depois de filtrar, ordenar e paginar |
| cell renderer (desenho da célula) | A função que transforma o valor da célula no que aparece na tela |
| server-side pagination (paginação no servidor) | O backend devolve uma página por vez, e a tabela recebe o total |
| virtualization (renderização por janela) | Só as linhas visíveis existem no DOM, e as demais entram conforme a rolagem |
As colunas ficam fora do componente, e num arquivo só
A definição das colunas é dado, e não interface. Declarar o array dentro do componente recria os objetos a cada render, o que faz a tabela recalcular o que não mudou. Declarar fora resolve isso e ainda deixa a coluna ser testada sem montar a tela.
O createColumnHelper amarra a coluna ao tipo da linha: o accessor que aponta para um campo que não existe em OrderView deixa de compilar, e renomear o campo acusa o erro na coluna que ficou para trás.
A célula formatada mora numa função com nome. Moeda, data e status são formatação, e formatação separada do cálculo é a mesma regra que vale no resto do guia.
❌ Ruim: as colunas nascem dentro do componente, e a data é formatada no meio do desenho
// features/orders/components/OrderTable.tsx
export function OrderTable({ orders }: OrderTableProps) {
const columns = [
{ accessorKey: "customerName", header: "Cliente" },
{
accessorKey: "createdAt",
header: "Criado em",
cell: (info: any) => new Date(info.getValue()).toLocaleDateString("pt-BR"),
},
{
accessorKey: "totalInCents",
header: "Total",
cell: (info: any) => `R$ ${(info.getValue() / 100).toFixed(2)}`,
},
];
const table = useReactTable({ data: orders, columns, getCoreRowModel: getCoreRowModel() });
return <TableShell table={table} />;
}
O any no info desliga a checagem: totalInCents poderia ser texto, e o toFixed só quebraria no navegador do usuário.
✅ Bom: as colunas moram no próprio arquivo, tipadas pela linha, e a formatação tem nome
// features/orders/components/order.columns.tsx
import { createColumnHelper } from "@tanstack/react-table";
import { formatCurrency, formatDate } from "@/lib/formatters";
import { OrderStatusBadge } from "./OrderStatusBadge";
import type { OrderView } from "../schemas/order.schema";
const columnHelper = createColumnHelper<OrderView>();
export const orderColumns = [
columnHelper.accessor("customerName", {
header: "Cliente",
}),
columnHelper.accessor("createdAt", {
header: "Criado em",
cell: (cell) => formatDate(cell.getValue()),
}),
columnHelper.accessor("totalInCents", {
header: "Total",
cell: (cell) => formatCurrency(cell.getValue()),
}),
columnHelper.accessor("status", {
header: "Situação",
cell: (cell) => <OrderStatusBadge status={cell.getValue()} />,
}),
];
// features/orders/components/OrderTable.tsx
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { orderColumns } from "./order.columns";
import type { OrderView } from "../schemas/order.schema";
interface OrderTableProps {
orders: OrderView[];
}
export function OrderTable({ orders }: OrderTableProps) {
const table = useReactTable({
data: orders,
columns: orderColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return <TableShell table={table} />;
}
O cell.getValue() chega tipado: createdAt é a data, totalInCents é o número, e o formatador recebe o que espera.
O array de linhas precisa manter a identidade entre renders
A tabela compara a referência do array para decidir se recalcula. Um array novo a cada render, vindo de um .map() ou de um .filter() escrito dentro do componente, faz a tabela reprocessar tudo a cada tecla digitada em qualquer campo da tela.
O dado que vem do TanStack Query já resolve isso: enquanto a chave da consulta não muda, o Query devolve a mesma referência. A transformação que precisa acontecer no cliente entra no select da consulta, que roda uma vez e guarda o resultado.
❌ Ruim: a lista é remontada no corpo do componente, e a tabela reprocessa a cada render
export function OrderTable() {
const { data: orders = [] } = useQuery(ordersQuery(status));
const visibleOrders = orders
.filter((order) => !order.isArchived)
.map((order) => ({ ...order, customerName: order.customer.name }));
const table = useReactTable({
data: visibleOrders,
columns: orderColumns,
getCoreRowModel: getCoreRowModel(),
});
return <TableShell table={table} />;
}
✅ Bom: a transformação acontece no select da consulta, e a referência se mantém entre renders
// features/orders/queries/order.queries.ts
import { queryOptions } from "@tanstack/react-query";
import { listOrders } from "../services/order.service";
import { toOrderRow } from "../services/order.mapper";
export function orderRowsQuery(status: StatusFilter) {
const options = queryOptions({
queryKey: ["orders", { status }],
queryFn: () => listOrders(status),
select: (orders) => orders.filter((order) => !order.isArchived).map(toOrderRow),
});
return options;
}
Acima de alguns milhares de linhas, a paginação vai para o servidor
A paginação no cliente exige que todas as linhas estejam no navegador. Ela serve enquanto a lista inteira cabe numa resposta, o que na prática vale até alguns milhares de linhas.
Passando disso, quem pagina é o backend, e a tabela deixa de calcular o recorte. Três coisas mudam: a página entra na chave da consulta, o manualPagination avisa a tabela para não recortar nada, e o total de linhas chega da resposta, porque o cliente não tem como contar o que ele nunca recebeu.
O placeholderData mantém a página anterior na tela enquanto a próxima chega, e a tabela para de piscar a cada clique em "próxima".
✅ Bom: o servidor recorta a página, e a tabela mostra a anterior enquanto a próxima chega
// features/orders/queries/order.queries.ts
import { queryOptions, keepPreviousData } from "@tanstack/react-query";
import { listOrdersPage } from "../services/order.service";
interface OrdersPageParams {
pageIndex: number;
pageSize: number;
status: StatusFilter;
}
export function ordersPageQuery(params: OrdersPageParams) {
const options = queryOptions({
queryKey: ["orders", "page", params],
queryFn: () => listOrdersPage(params),
placeholderData: keepPreviousData,
});
return options;
}
// features/orders/components/OrderTable.tsx
export function OrderTable() {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
const status = useOrderFilterStore((state) => state.status);
const { data: page, isPending } = useQuery(ordersPageQuery({ ...pagination, status }));
const table = useReactTable({
data: page?.rows ?? [],
columns: orderColumns,
rowCount: page?.totalCount ?? 0,
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
});
if (isPending) return <TableSkeleton />;
return <TableShell table={table} />;
}
O rowCount vem da resposta. Sem ele, a tabela conta as linhas que recebeu, e o rodapé anuncia 25 resultados quando existem 4.000.
A rolagem longa renderiza só o que está na tela
Uma tabela que mostra milhares de linhas de uma vez coloca milhares de nós no DOM, e a rolagem trava. A virtualização mantém no DOM apenas as linhas visíveis, mais algumas de folga acima e abaixo, e reaproveita esses nós conforme o usuário rola.
Ela entra quando a tela precisa mesmo mostrar a lista longa sem paginar, como num relatório que se percorre de ponta a ponta. Quando a paginação resolve a tarefa do usuário, ela é o caminho mais simples, e a virtualização não é necessária.
✅ Bom: só as linhas visíveis existem no DOM, e o container reserva a altura total
// features/orders/components/VirtualOrderTable.tsx
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
const ROW_HEIGHT_IN_PX = 48;
const OVERSCAN_ROW_COUNT = 8;
export function VirtualOrderTable({ table }: VirtualOrderTableProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => ROW_HEIGHT_IN_PX,
overscan: OVERSCAN_ROW_COUNT,
});
const visibleRows = rowVirtualizer.getVirtualItems();
const totalHeightInPx = rowVirtualizer.getTotalSize();
return (
<div ref={scrollContainerRef} className="order-table__scroll">
<div style={{ height: `${totalHeightInPx}px` }}>
{visibleRows.map((virtualRow) => (
<OrderRow
key={rows[virtualRow.index].id}
row={rows[virtualRow.index]}
offsetInPx={virtualRow.start}
/>
))}
</div>
</div>
);
}
O <div> interno reserva a altura de todas as linhas, e por isso a barra de rolagem tem o tamanho certo mesmo com poucas linhas no DOM.
Próximos passos
- React SPA: o Query, o Router e a store que a tabela consome.
- Performance: o custo do render e o que medir antes de otimizar.
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.