Construindo um Data Grid com TanStack Table
Crie uma grade de dados com TanStack Table e adicione ordenação, filtros, paginação e virtualização, além de corrigir bugs de memoização.
TanStack Table é uma biblioteca headless: ela cuida da lógica da tabela (ordenação, filtragem, paginação e modelos de linha), enquanto você tem controle total sobre a marcação e o estilo.
Se você já passou uma tarde inteira lutando contra a folha de estilos de um grid pré-construído só para ajustar uma borda, essa troca fará sentido imediato. Você obtém um motor de estado battle-tested e uma tela em branco para o DOM — exatamente o que você quer quando o design system é seu.
Este guia monta um data grid real na versão estável atual, @tanstack/react-table v8: uma única tabela que ordena, filtra globalmente e pagina. Em seguida, abordamos os dois problemas de escalabilidade que realmente aparecem em produção: o bug de memoização que causa re-renders infinitos e a virtualização para milhares de linhas.
Principais Conclusões
- No TanStack Table v8, renderize cada cabeçalho e célula com
flexRender:flexRender(header.column.columnDef.header, header.getContext())para cabeçalhos eflexRender(cell.column.columnDef.cell, cell.getContext())para células. As chamadascell.render('Cell')ecolumn.render('Header')da era v7 não existem mais. - Ordenação, filtragem e paginação são row models opcionais: passe
getSortedRowModel(),getFilteredRowModel()egetPaginationRowModel()em uma única chamadauseReactTablee a ordem não importa. - O bug mais comum no TanStack Table é um loop de re-render infinito causado por passar um array
dataoucolumnsnovo a cada render. Envolva ambos emuseMemoou defina-os no escopo do módulo. - Com mais de alguns milhares de linhas no lado do cliente, pare de paginar e virtualize com o
useVirtualizerdo@tanstack/react-virtualsobretable.getRowModel().rows. - A versão estável é a v8, atualmente 8.21.3 na tag
latestdo npm. A v9 entrou em beta em junho de 2026, portanto mantenha as tabelas em produção na v8.
O que significa “headless” e por que isso importa
Headless significa que a biblioteca fornece comportamento, não UI. O TanStack Table computa row models e expõe estado e handlers, mas não insere nada no DOM: a v8 deixou de incluir estilos padrão e atributos role para que o núcleo pudesse permanecer agnóstico em relação ao framework. A vantagem é o controle total. A responsabilidade é que a semântica também é sua — portanto, <table>, <th scope> e quaisquer roles ARIA ficam por sua conta.
Na prática, isso significa que o estilo é bring-your-own: classes utilitárias do Tailwind, CSS Modules ou styled components na sua própria marcação <table>. A biblioteca nunca a toca.
Configurando um grid básico com TanStack Table
Discover how at OpenReplay.com.
Instale o pacote e construa a tabela com o hook useReactTable. Defina colunas com createColumnHelper para inferência de tipos, passe seus data e columns, e registre getCoreRowModel(), o row model base que mapeia seus dados em linhas.
npm install @tanstack/react-table
O adapter suporta todas as versões do React de 16.8 até a 19, com a ressalva de que pode não se comportar corretamente com o React Compiler que acompanha o 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' }),
]
Renderize percorrendo getHeaderGroups(), getRowModel().rows e os getVisibleCells() de cada linha. Passe a definição da coluna e seu contexto para flexRender, de modo que cabeçalhos em string, JSX e células como componentes sejam todos resolvidos corretamente. Conforme o guia de migração da v8, flexRender substitui as chamadas removidas 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>
)
}
Adicionando ordenação
Habilite a ordenação adicionando getSortedRowModel(), mantendo o estado sorting com onSortingChange e conectando cada cabeçalho a header.column.getToggleSortingHandler(). Use header.column.getIsSorted() (que retorna 'asc', 'desc' ou false) para exibir o indicador de direção.
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>
Filtragem e busca global
A busca global é um único estado: adicione getFilteredRowModel(), mantenha uma string globalFilter em state e atualize-a com onGlobalFilterChange. O filtro global padrão realiza correspondência de substrings em todas as colunas.
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)} />
Os filtros por coluna utilizam o mesmo row model, controlados por column.getFilterValue() e column.setFilterValue() a partir de um input no cabeçalho. A correspondência fuzzy está disponível, mas requer o helper separado @tanstack/match-sorter-utils como um filterFn personalizado. Recorra a ele apenas quando a correspondência por substring não for suficiente.
Paginação
Adicione getPaginationRowModel() e controle-o com o estado pageIndex/pageSize; em seguida, condicione seus botões Próximo/Anterior com table.getCanNextPage() e table.getCanPreviousPage(). A ordem de registro dos row models não importa na v8, portanto eles se compõem livremente junto com ordenação e filtragem na mesma chamada do 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>
Isso é um grid completo e funcional: uma única chamada useReactTable que ordena, filtra e pagina em conjunto.
Escalabilidade: o problema de memoização, virtualização e células personalizadas
O bug mais comum no TanStack Table é um loop de re-render infinito causado por passar um array data ou columns novo a cada render. Declarar const columns = [...] ou const data = [...] dentro do corpo do componente cria uma nova referência a cada passagem; o TanStack Table detecta a mudança de identidade, recomputa os row models, dispara um re-render e o ciclo se repete. Envolva ambos em useMemo (ou defina-os no escopo do módulo, como nos exemplos anteriores) para que suas referências permaneçam estáveis.
const columns = useMemo(() => [/* ... */], [])
const data = useMemo(() => fetchedRows, [fetchedRows])
Paginar ou virtualizar? Pagine quando os tamanhos de página são limitados ou os dados são buscados por página a partir de um servidor. Virtualize quando você mantém milhares de linhas no lado do cliente em uma única view de scroll contínuo. Para esse caso, renderize apenas as linhas visíveis com @tanstack/react-virtual e seu hook useVirtualizer sobre table.getRowModel().rows. Observe que o hook atual da v3 é useVirtualizer, não o antigo 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,
})
Em seguida, renderize rowVirtualizer.getVirtualItems() dentro de um espaçador dimensionado por getTotalSize(), conforme demonstrado na documentação do TanStack Virtual. Este é o tipo de falha que o session replay é eficaz em expor: um grid com 10 mil linhas sem virtualização parece aceitável com um dataset de seed localmente, mas reproduzir um usuário real ordenando ou fazendo scroll rápido em produção é onde os frames perdidos e o scroll jank aparecem.
Células personalizadas são simplesmente o renderer cell da coluna: retorne um badge de status, uma data formatada ou um input editável, e a biblioteca não interfere porque nunca renderiza UI por conta própria. Fixação de colunas (column pinning), redimensionamento e reordenação também são suportados por meio de suas próprias APIs de estado.
Conclusão
Agora você tem um grid que ordena, filtra e pagina em uma única chamada useReactTable, além das duas estratégias que o mantêm performático: referências estáveis de data/columns e virtualização para mais de alguns milhares de linhas. Construa com a v8 hoje, pois a v9 está em beta desde junho de 2026, e recorra ao useVirtualizer no momento em que a contagem de linhas ultrapassar o tamanho de uma página.
Perguntas Frequentes
Por que meu TanStack Table está re-renderizando infinitamente?
Um loop de re-render infinito quase sempre é causado por passar um array data ou columns novo a cada render. Declarar const columns = [] ou const data = [] dentro do corpo do componente cria uma nova referência a cada passagem, fazendo com que o TanStack Table detecte a mudança de identidade, recompute seus row models e dispare outro render. Envolva ambos em useMemo ou defina-os no escopo do módulo para que suas referências permaneçam estáveis.
Devo usar TanStack Table v8 ou v9?
Use a v8 em produção. A versão estável é a v8 (8.21.3 na tag latest do npm), enquanto a v9 está em beta desde junho de 2026 e ainda é distribuída apenas como builds de pré-lançamento sob a tag beta do npm. A v8 funciona com React 16.8, 17, 18 e 19. Mantenha as tabelas em produção na v8 e experimente a v9 apenas em código não crítico até que ela alcance uma versão estável.
Quando devo paginar versus virtualizar um TanStack Table?
Pagine quando os tamanhos de página são limitados ou os dados são buscados por página a partir de um servidor, usando getPaginationRowModel e o estado pageIndex/pageSize. Virtualize quando você mantém milhares de linhas no lado do cliente em uma única view de scroll contínuo: renderize apenas as linhas visíveis com o useVirtualizer do @tanstack/react-virtual sobre table.getRowModel().rows. As duas abordagens não são mutuamente exclusivas, mas com mais de alguns milhares de linhas no lado do cliente em uma única área de scroll, a virtualização é a escolha correta.
O TanStack Table fornece algum estilo padrão ou roles de acessibilidade?
Não. O TanStack Table v8 é totalmente headless. Ele não produz marcação, estilos nem atributos role; a reescrita da v8 eliminou tudo isso para que o núcleo pudesse funcionar com qualquer framework. Você é responsável por todo o DOM, portanto marcação semântica como table, th com scope e quaisquer roles ARIA são de sua responsabilidade. Estilize com Tailwind, CSS Modules ou styled components na sua própria marcação.
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