Pular para o conteúdo

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

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 e aria-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

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 Table do 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-community e 1,6 milhões do @mui/x-data-grid)
  • O processamento no servidor é um padrão de primeira classe: manualPagination, manualSorting e manualFiltering significam “estes dados já estão processados”, e o rowCount vem 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 useReactTable da 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

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-community 36.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

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.

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.

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 OFFSET grande é 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”

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.

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-table 9.x. Reexporta o @tanstack/table-core — nunca instalar o core em separado. Dar a cada tabela uma key e registá-la no @tanstack/react-table-devtools apenas em desenvolvimento
  • Escrever só v9: useTable, tableFeatures, table.FlexRender. Os snippets da v8 (useReactTable, getCoreRowModel, getSortedRowModel) não compilam, e a ponte useLegacyTable está 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 as filterFns / sortFns que as tabelas usam, e exporta typeof features para os genéricos de ColumnDef, Row e Table. stockFeatures só 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 a table. O DataTable é apresentacional: renderiza o Table* do shadcn/ui a partir de uma instância table e 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ão row): row e column sã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, e getRowId: (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, TableCell do shadcn/ui em src/components/ui/table.tsx (ADR-0011); toda a tabela tem um TableCaption (visualmente oculto serve) ou um aria-label. O controlo de ordenação é um Button dentro do th — nunca onClick no th — e aria-sort="ascending|descending" vai só no cabeçalho ordenado. O guia do shadcn/ui não define aria-sort, por isso um DataTableColumnHeader partilhado fá-lo, e os ecrãs não se podem esquecer. Os ícones de ordenação são aria-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 tupla const), dir, q e filtros. A server function valida o mesmo schema (ADR-0005), e o loaderDeps lista 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 com useSuspenseQuery. A server function GET devolve { rows, rowCount }. Os data do useSuspenseQuery estão sempre definidos e têm referência estável, o que a tabela exige — nunca data ?? [] em linha
  • O Postgres faz o trabalho. O handler mapeia sort por uma lista permitida para colunas do Drizzle e aplica WHERE, ORDER BY, LIMIT e OFFSET. Todo o ORDER BY termina 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, manualSorting e manualFiltering todos a true, rowCount vindo do servidor, state derivado dos search params. onSortingChange e onPaginationChange chamam navigate({ search: (prev) => … }), resolvendo os updaters com functionalUpdate. Mudanças de ordenação, filtro e tamanho de página repõem page a 1 — o manualPagination desliga 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 em useState, faz-lhe debounce (ADR-0023) e navega com replace: true
  • Nunca misturar os dois modos: nada de ordenar ou filtrar no cliente sobre uma página do servidor. Com manualPagination, as outras duas flags manual* 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 functions POST que 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: useTable com row model ordenado, através de renderToString). features e columns são constantes de módulo porque não guardam dados de utilizadores; as linhas, o estado e o QueryClient nunca ficam ao nível do módulo (ADR-0012)
  • As tabelas estáticas são o Table do 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.

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 } = row deixa de funcionar — chamar row.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-sort e 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 row ou column a 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.