ADR-0018 Tabelas
Tabelas padrão da empresa — TanStack Table v9, headless, renderizado com o markup de tabela do shadcn/ui; as listas que crescem são paginadas, ordenadas e filtradas no servidor através do URL e do Query.
- Decisão
- TanStack Table v9 + shadcn/ui TableConstruir a app
- Estado
- Aceite
- Data
- Âmbito
- Predefinição da empresa para todas as novas apps
Ligações
Contexto
Seção intitulada “Contexto”Todos os produtos mostram listas de registos: ecrãs de administração, relatórios, registos de auditoria, resultados de pesquisa. As decisões à volta estão tomadas:
- Componentes: shadcn/ui sobre Base UI com Tailwind v4 (ADR-0011). O componente
Tableé markup simples (table,thead,tr,th,td) sem estado próprio, e o guia de data table do shadcn/ui é escrito para o TanStack Table - Dados: TanStack Query por trás dos loaders do Router, e estado de UI partilhável nos search params do URL validados pelo Zod (ADR-0012). As leituras são server functions
GET, que o limitador não cobre (ADR-0010) - Base de dados: Postgres através do Drizzle (ADR-0008), pelo que a ordenação, a filtragem e a paginação podem correr em SQL
- SSR: TanStack Start (ADR-0002) — uma tabela deve chegar no primeiro HTML, não depois da hidratação
O que está em aberto: quem é dono do estado da tabela (ordenação, filtros, página, seleção, visibilidade de colunas), onde as linhas são processadas (browser ou Postgres) e o que renderiza o markup.
Forças em jogo:
- Markup de tabela a sério.
<table>, células de cabeçalho earia-sort— os leitores de ecrã navegam as tabelas por linha e por coluna - O estado que vale a pena partilhar vive no URL. Uma página 3 ordenada e filtrada tem de sobreviver a um recarregamento e poder ser colada num link
- Sem um segundo design system. As tabelas usam os nossos tokens e componentes, sem CSS-in-JS em runtime (ADR-0011)
- Os dados crescem. Uma tabela que funciona com 200 linhas no primeiro mês tem 200 000 no segundo ano, e a correção não pode ser uma reescrita. Conjuntos muito grandes precisam também de virtualização (ADR-0019)
- As tabelas estáticas continuam triviais. Uma tabela de cinco linhas só de leitura não deve precisar de uma biblioteca
Opções consideradas
Seção intitulada “Opções consideradas”Biblioteca de tabelas
Seção intitulada “Biblioteca de tabelas”1. TanStack Table v9 (headless)
Seção intitulada “1. TanStack Table v9 (headless)”A lógica e o estado da tabela — ordenação, filtragem, paginação, seleção, visibilidade de colunas, pinning, agrupamento, expansão — como um hook; o markup é nosso. @tanstack/react-table 9.3.1, estável desde 2026-08-04.
Prós
- Headless: o markup é nosso, pelo que o
Tabledo shadcn/ui e os nossos tokens Tailwind aplicam-se sem alterações e a página é uma<table>a sério. O guia de data table do shadcn/ui já está escrito para a v9 - Mesma equipa do Router, do Query, do Start e do Form; MIT; o núcleo de tabelas para React mais usado por larga margem (13,9 milhões de downloads no npm na semana até 2026-10-18, contra 1,9 milhões do
ag-grid-communitye 1,6 milhões do@mui/x-data-grid) - O processamento no servidor é um padrão de primeira classe:
manualPagination,manualSortingemanualFilteringsignificam “estes dados já estão processados”, e orowCountvem do servidor. A tabela nunca faz fetch; o seu guia indica o TanStack Query como companheiro - A v9 é mais leve em escala. Os benchmarks dos mantenedores mostram até 86% menos heap retida com um milhão de linhas (cerca de 2,71 GB para 380 MB, paginado, oito colunas) e, em média, menos 37% de tempo a ordenar e menos 34% a filtrar com 20 000 linhas ou mais
- Tree-shakeable: as funcionalidades são opt-in através de
tableFeatures(), e uma tabela sem funcionalidades opcionais começa com cerca de 5 kB de JS no bundle - Funciona corretamente com o React Compiler; o
useReactTableda v8 era uma incompatibilidade documentada - Combina com o TanStack Virtual para conjuntos grandes, com guia e exemplos na sua documentação (ADR-0019)
Contras
- A v9 tem 11 semanas (estável em 2026-08-04, depois de mais de dois anos de trabalho; a 9.3.1 saiu em 2026-10-12). A maioria dos artigos, respostas e snippets gerados por IA ainda mostra a v8 (
useReactTable,getCoreRowModel), que não compila com a v9 - Headless significa que construímos a toolbar, o cabeçalho de coluna, a paginação e os filtros; o shadcn/ui dá um guia e blocos, não um componente
- A v9 estável depende de
@tanstack/react-store^0.11.2, que é pré-1.0 (o ADR-0012 indica o TanStack Store como pré-1.0) - Sem row model no servidor, sem UI de agrupamento ou exportação, sem UI de edição de células: o contrato com o servidor é nosso
- O pacote completo cresceu (14 kB para 25 kB, segundo o anúncio da v9); só as tabelas que registam poucas funcionalidades ficam menores do que na v8
2. AG Grid (Community / Enterprise)
Seção intitulada “2. AG Grid (Community / Enterprise)”Um widget de grid completo, dono do seu DOM, da virtualização e do tema.
Prós
- Tudo incluído: virtualização, redimensionamento e pinning de colunas, navegação por teclado na grid; a Community é MIT (
ag-grid-community36.2.0)
Contras
- Row model no servidor, agrupamento de linhas, pivoting, exportação para Excel, gráficos integrados e master/detail são Enterprise: uma licença comercial listada a partir de 999 USD por programador, perpétua com um ano de atualizações
- O seu próprio DOM e sistema de temas (Theme Builder, parâmetros de tema) em vez do markup do shadcn/ui e dos nossos tokens — um segundo design system
3. MUI X Data Grid
Seção intitulada “3. MUI X Data Grid”Prós: capaz, maduro (@mui/x-data-grid 9.16.0); o pacote Community é MIT.
Contras: precisa do Material UI e do Emotion como peer dependencies (@mui/material, @emotion/react, @emotion/styled), contra a regra do ADR-0011 de não usar CSS-in-JS em runtime. A grid MIT está limitada a páginas de até 100 linhas; o pinning de colunas, a virtualização de linhas e páginas maiores exigem o plano comercial Pro, e o agrupamento de linhas e a exportação para Excel exigem o Premium.
4. Material React Table / Mantine React Table
Seção intitulada “4. Material React Table / Mantine React Table”Prós: TanStack Table debaixo de uma UI pronta; MIT.
Contras: presos ao design system do Material UI ou do Mantine, e fixos na v8 do TanStack Table (o material-react-table 3.2.1 depende de @tanstack/react-table 8.20.6, publicado pela última vez em 2025-03-01; a última versão estável do mantine-react-table, a 1.3.4, é de 2025-02-17, e a sua v2 ainda está em beta). Nenhum tem versão na v9.
5. Tabelas feitas à mão (Table do shadcn/ui + .map())
Seção intitulada “5. Tabelas feitas à mão (Table do shadcn/ui + .map())”Prós: zero dependências; exatamente o certo para tabelas estáticas. Contras: o estado de ordenação, filtragem, paginação, seleção e visibilidade reconstruído em cada produto, sempre ligeiramente diferente.
Onde as linhas são processadas
Seção intitulada “Onde as linhas são processadas”A. No servidor por predefinição, no cliente para conjuntos limitados
Seção intitulada “A. No servidor por predefinição, no cliente para conjuntos limitados”O Postgres ordena, filtra e pagina; o browser recebe uma página mais o rowCount.
Prós
- Escala com os dados, não com a memória do browser nem com o tamanho da resposta; as permissões e as regras de consulta ficam no servidor
- Encaixa no Query e no URL: os search params são a chave da query
- O próprio guia do TanStack: quando o servidor é dono da paginação, deve ser também dono da filtragem e da ordenação
Contras
- Cada interação é um pedido, e cada coluna ordenável ou filtrável precisa de um caminho em SQL e de um índice
- A paginação por número de página precisa de uma ordem única, senão as páginas repetem e saltam linhas; um
OFFSETgrande é calculado no servidor e fica lento (documentação do PostgreSQL). A paginação por keyset resolve ambos os problemas, mas perde os números de página e a “última página”
B. No cliente para tudo
Seção intitulada “B. No cliente para tudo”Prós: interações instantâneas, sem código no servidor; o guia do TanStack considera praticáveis alguns milhares de linhas e testa em stress muitas mais. Contras: envia o conjunto de dados inteiro — bytes, memória e linhas que o utilizador pode não ter permissão para ver; desatualiza-se; deixa de funcionar quando os dados crescem. Ordenar no cliente uma página vinda do servidor ordena apenas essa página.
Decisão
Seção intitulada “Decisão”TanStack Table v9, headless, renderizado com o markup de tabela do shadcn/ui, é o padrão da empresa para tabelas interativas. As listas que podem exceder uma resposta são paginadas, ordenadas e filtradas no servidor, com o estado no URL e as linhas no Query. As listas limitadas podem processar as linhas no browser. As tabelas estáticas usam apenas o Table do shadcn/ui.
Em concreto:
- Pacote:
@tanstack/react-table9.x. Reexporta o@tanstack/table-core— nunca instalar o core em separado. Dar a cada tabela umakeye registá-la no@tanstack/react-table-devtoolsapenas em desenvolvimento - Escrever só v9:
useTable,tableFeatures,table.FlexRender. Os snippets da v8 (useReactTable,getCoreRowModel,getSortedRowModel) não compilam, e a ponteuseLegacyTableestá obsoleta e carrega todas as funcionalidades — nenhum código novo a usa - Um módulo de features por família de tabelas (
data-table-features.ts, como no guia do shadcn/ui):tableFeatures({ … })regista apenas as funcionalidades, os row models e asfilterFns/sortFnsque as tabelas usam, e exportatypeof featurespara os genéricos deColumnDef,RoweTable.stockFeaturessó em protótipos - Os hooks são donos dos dados e do estado; os componentes renderizam. Um hook da funcionalidade (
useProjectsTable(search)) lê o Query e o URL e devolve atable. ODataTableé apresentacional: renderiza oTable*do shadcn/ui a partir de uma instânciatablee nunca faz fetch nem navega. A toolbar, o cabeçalho de coluna e a paginação recebem os valores e callbacks estritamente necessários (pageIndex,pageCount,onPageChange), não a tabela inteira. Passar valores às células, não objetos da Table (isSelected,onToggle, nãorow):rowecolumnsão referências estáveis, pelo que, com o React Compiler, um filho que leia estado através delas pode renderizar desatualizado - As colunas são constantes de módulo criadas com
createColumnHelper, egetRowId: (row) => row.idé sempre definido — a seleção e a expansão têm de sobreviver a mudanças de página, e os índices das linhas não sobrevivem - Markup de tabela a sério, definido uma só vez:
Table,TableHeader,TableRow,TableHead,TableCelldo shadcn/ui emsrc/components/ui/table.tsx(ADR-0011); toda a tabela tem umTableCaption(visualmente oculto serve) ou umaria-label. O controlo de ordenação é umButtondentro doth— nuncaonClicknoth— earia-sort="ascending|descending"vai só no cabeçalho ordenado. O guia do shadcn/ui não definearia-sort, por isso umDataTableColumnHeaderpartilhado fá-lo, e os ecrãs não se podem esquecer. Os ícones de ordenação sãoaria-hidden - O URL é o estado da tabela. Um schema Zod 4 de pesquisa em
validateSearch, com fallbacks.catch():page,pageSize(de uma lista curta permitida),sort(um id de coluna de uma tuplaconst),dir,qe filtros. A server function valida o mesmo schema (ADR-0005), e oloaderDepslista exatamente essas chaves - O Query é dono das linhas, como no ADR-0012: uma factory de options
projectsTableOptions(search)com a chave["projects", "list", search], aguardada no loader e lida comuseSuspenseQuery. A server functionGETdevolve{ rows, rowCount }. OsdatadouseSuspenseQueryestão sempre definidos e têm referência estável, o que a tabela exige — nuncadata ?? []em linha - O Postgres faz o trabalho. O handler mapeia
sortpor uma lista permitida para colunas do Drizzle e aplicaWHERE,ORDER BY,LIMITeOFFSET. Todo oORDER BYtermina com um critério de desempate único (id) — sem uma ordem previsível, páginas diferentes dão resultados inconsistentes. Nunca pôr texto do cliente no SQL. Indexar as colunas por que deixamos ordenar e filtrar. As tabelas muito grandes ou que só crescem usam paginação por keyset (pageCount: -1, sem “última página”) - Ligar a tabela ao URL:
manualPagination,manualSortingemanualFilteringtodos atrue,rowCountvindo do servidor,statederivado dos search params.onSortingChangeeonPaginationChangechamamnavigate({ search: (prev) => … }), resolvendo os updaters comfunctionalUpdate. Mudanças de ordenação, filtro e tamanho de página repõempagea 1 — omanualPaginationdesliga o reset automático, pelo que o fazem os handlers. As mudanças de ordenação e de página acrescentam ao histórico; o filtro de texto guarda o rascunho emuseState, faz-lhe debounce (ADR-0023) e navega comreplace: true - Nunca misturar os dois modos: nada de ordenar ou filtrar no cliente sobre uma página do servidor. Com
manualPagination, as outras duas flagsmanual*também ficam ativas - Tabelas no cliente só para conjuntos limitados que seja seguro enviar por inteiro — alguns milhares de linhas são praticáveis, mas medir com colunas e dispositivos reais — com os row models de cliente registados (
createSortedRowModel(),createFilteredRowModel(),createPaginatedRowModel()). Nunca enviar linhas que o utilizador não possa ver - A seleção é estado local indexado por
getRowId; não atravessa páginas a menos que guardemos os ids. As ações em massa são server functionsPOSTque voltam a verificar no servidor (ADR-0013) - Paginar por predefinição; virtualizar só quando necessário: quando os utilizadores têm de percorrer milhares de linhas carregadas, usar o TanStack Virtual sobre
table.getRowModel().rows(ADR-0019) - SSR: a tabela renderiza no primeiro HTML (verificado na 9.3.1:
useTablecom row model ordenado, através derenderToString).featuresecolumnssão constantes de módulo porque não guardam dados de utilizadores; as linhas, o estado e oQueryClientnunca ficam ao nível do módulo (ADR-0012) - As tabelas estáticas são o
Tabledo shadcn/ui mais.map(). O TanStack Table entra quando uma tabela precisa de ordenação, filtros, paginação, seleção ou visibilidade de colunas - Testes: os cliques de ordenação e as mudanças de página atualizam o URL, cobertos por testes de componentes em browser mode; o axe corre nos percursos que mostram tabelas (ADR-0014)
Os fatores decisivos: markup nosso, pelo que o shadcn/ui e os nossos tokens se aplicam sem um segundo design system; estado que encaixa no URL e no Query que já escolhemos; um padrão documentado de processamento no servidor que escala com os dados; e uma só biblioteca, da mesma equipa do nosso router, que também alimenta a virtualização. Aceitamos uma major jovem, ser donos dos componentes de toolbar e de cabeçalho de coluna, e ser donos do contrato com o servidor.
Consequências
Seção intitulada “Consequências”Mais fácil:
- Uma só API de tabelas em todos os produtos; o estado de ordenação, filtros e página é linkável, sobrevive a recarregamentos e tem os tipos dos schemas Zod que já temos
- As tabelas renderizam no servidor e hidratam sem um segundo fetch
- Cabeçalhos acessíveis por predefinição: o cabeçalho de coluna partilhado é dono do
aria-sort, dos botões de ordenação focáveis e dos ícones ocultos - Crescer é uma mudança de configuração, não uma reescrita: as tabelas limitadas passam para o servidor ligando as flags
manual*e acrescentando a server function; as gigantes acrescentam o ADR-0019
Mais difícil:
- A v9 é jovem: os exemplos da comunidade são maioritariamente da v8, pelo que se adaptam em vez de se copiarem. Os métodos da v9 vivem em protótipos partilhados, pelo que
const { getValue } = rowdeixa de funcionar — chamarrow.getValue() - Os componentes de
data-table(cabeçalho de coluna, toolbar, paginação, módulo de features) são nossos para construir e manter; são o primeiro candidato para o registry partilhado do shadcn/ui (ADR-0011) - Cada tabela no servidor precisa de uma lista permitida de colunas, de um índice por coluna ordenável, de um critério de desempate único e de uma query de
count; a paginação por número de página pode desviar-se quando as linhas mudam entre páginas - O
aria-sorte a semântica de tabela são trabalho nosso; o axe apanha estrutura partida, não um estado de ordenação em falta - Com o React Compiler, passar objetos
rowoucolumna filhos memoizados pode mostrar células desatualizadas — passar valores, ou subscrever dentro do filho - O ciclo de lançamentos do Table é separado do Query, do Router e do Start; e arrasta também o TanStack Store pré-1.0
Do que abdicamos: das funcionalidades completas do AG Grid (row model no servidor, pivoting, exportação para Excel, gráficos integrados), da grid pronta do MUI X e do atalho de uma UI de tabela já empacotada. Aceite — markup próprio e um só design system pesam mais numa predefinição da empresa; um produto que precise de uma grid ao estilo folha de cálculo com pivoting e exportação para Excel pode escrever um ADR de exceção para o AG Grid Enterprise.

