Pular para o conteúdo

ADR-0019 Listas virtuais

Listas virtuais padrão da empresa — TanStack Virtual, headless, para listas longas, feeds e grids grandes quando a paginação e o CSS não chegam.

Decisão
TanStack VirtualConstruir a app
Estado
Aceite
Data
Âmbito
Predefinição da empresa para todas as novas apps

Alguns ecrãs mostram mais linhas do que um browser deveria renderizar de uma só vez: visualizadores de logs, registos de auditoria, seletores longos, chat e output de IA em streaming, tabelas grandes (ADR-0018). Cada linha a mais são nós de DOM, trabalho de layout e memória. A virtualização renderiza apenas as linhas na viewport de scroll mais um pequeno buffer.

Tem custos, por isso não é a primeira resposta. As decisões à volta:

  • React e SSR: TanStack Start (ADR-0002). Um servidor não tem layout, pelo que um virtualizador não sabe quantas linhas cabem
  • Componentes: shadcn/ui sobre Base UI com Tailwind v4 (ADR-0011); markup nosso, tokens nossos
  • Dados: Query e estado no URL (ADR-0012). A virtualização renderiza dados que já estão no browser; não reduz o que é obtido
  • Tabelas: paginadas no servidor por predefinição (ADR-0018); a virtualização é a exceção para conjuntos carregados muito grandes
  • Testes: os testes de componentes correm num browser real (ADR-0014), que é o que um virtualizador precisa — um DOM simulado não tem layout para medir

O que está em aberto: que ferramenta, e as regras que mantêm as listas virtualizadas acessíveis.

Forças em jogo:

  • Muitas vezes não é preciso. A paginação, a filtragem no servidor ou CSS simples podem resolver o mesmo problema com menos risco
  • Acessibilidade. As linhas fora da viewport não estão no DOM: a pesquisa na página do browser e as tecnologias de apoio não lhes chegam, e uma linha com foco pode ser desmontada ao fazer scroll
  • SSR. O primeiro HTML não deve ser uma lista vazia
  • Headless. As linhas são linhas de tabela do shadcn/ui, itens de lista ou cartões — a biblioteca não pode ditar o markup
  • Encaixe com o TanStack Table. As grids grandes combinam as linhas de table.getRowModel() com um virtualizador (ADR-0018)

Virtualizador headless: um hook que devolve que itens renderizar e onde. @tanstack/react-virtual 3.14.x sobre @tanstack/virtual-core 3.19.x.

Prós

  • Headless: sem markup nem estilos, pelo que funciona sobre linhas de tabela do shadcn/ui, itens ul ou cartões; layouts vertical, horizontal e em grid (lanes, ou um virtualizador por eixo); useWindowVirtualizer para scroll ao nível da página
  • O caminho que o TanStack Table documenta: o seu guia de virtualização, com exemplos de linhas, colunas e scroll infinito, assenta nele (ADR-0018)
  • Cobre as partes difíceis: medição dinâmica (measureElement), scrollToIndex, rangeExtractor (manter itens escolhidos renderizados), initialRect e initialOffset para SSR, restauro do scroll (takeSnapshot) e feeds ancorados ao fim para chat e logs (anchorTo: "end", followOnAppend)
  • Mesma equipa do Router, do Query, do Start e do Table; MIT; v3 estável desde 2023-12-01; 12 lançamentos entre 2026-08-18 e 2026-10-16 (o react-virtual 3.14.21 é o mais recente); a opção mais usada (16,4 milhões de downloads no npm na semana até 2026-10-18, contra 3,4 milhões do react-window)

Contras

  • Headless significa que a acessibilidade é nossa: não renderiza roles, ARIA nem tratamento de teclado, e a sua documentação não cobre nenhum dos três
  • Os tamanhos são estimativas até serem medidos; um estimateSize fraco com alturas dinâmicas causa saltos de scroll e uma barra de scroll instável. A documentação recomenda estimar o maior tamanho, e o guia do TanStack Table diz para preferir tamanhos de linha fixos
  • No servidor não renderiza linhas sem initialRect (verificámos na 3.14.21: 0 linhas sem ele, 7 com uma viewport de 200 px e linhas de 35 px)
  • No React 19, o scroll pode registar um aviso de flushSync; a correção documentada é useFlushSync: false, que a documentação descreve como trocar alguma precisão de scroll por atualizações mais suaves
  • Dois números de versão: o adaptador React e o core são lançados em separado, e cada versão do adaptador fixa um core exato

Componentes para listas e grids de tamanho fixo e variável. 2.4.1, MIT.

Prós

  • Pequeno e focado: List e Grid, dimensionamento sem AutoSizer, roles ARIA predefinidos, e os componentes de linha recebem ariaAttributes
  • A melhor história de renderização no servidor do grupo: defaultHeight decide quantas linhas são renderizadas no servidor, com defaultScrollOffset e initialRowHeights acrescentados na 2.4.0
  • MIT; 3,4 milhões de downloads no npm na semana até 2026-10-18

Contras

  • Em forma de componente (rowComponent mais rowProps), não de hook; o guia de virtualização do TanStack Table não o usa, pelo que uma grid implica ligação nossa
  • As alturas de linha dinâmicas “não são tão eficientes como os tamanhos predeterminados” (o seu README)
  • A v2 (2025-08-28) foi uma reescrita: os snippets e tutoriais da v1 com FixedSizeList já não se aplicam

Prós: itens de tamanho variável sem medição manual; Virtuoso, GroupedVirtuoso (cabeçalhos de grupo sticky), VirtuosoGrid, TableVirtuoso (documentado como compatível com o TanStack Table) e masonry; o core é MIT (react-virtuoso 4.18.19). Contras: os componentes renderizam o seu próprio scroller e wrappers de itens, pelo que o controlo do markup passa por slots; o componente de chat Message List tem licença comercial; uma segunda forma de virtualizar ao lado da que o TanStack Table documenta.

Prós: zero configuração, com dimensionamento dinâmico automático; os componentes começam em cerca de 3 kB gzipped e são tree-shakeable; VList, VGrid, keepMounted, SSR através de ssrCount; MIT. Contras: pré-1.0 (0.53.5) — a API ainda pode mudar debaixo de um padrão da empresa; comunidade mais pequena (0,66 milhões de downloads no npm na semana até 2026-10-18); um elemento <table> a sério precisa de personalização (a sua própria tabela de comparação di-lo), e a documentação do TanStack Table não o cobre.

Sem JavaScript: o browser salta a renderização e o layout das subárvores fora do ecrã.

Prós

  • O conteúdo fora do ecrã continua no DOM e na árvore de acessibilidade, e a pesquisa na página, a ordem de tabulação e o foco continuam a funcionar (MDN)
  • Baseline desde setembro de 2024 (Safari 18, Firefox 125); sem dependências; o SSR é trivial
  • A demo do web.dev passou de 232 ms para 30 ms de renderização inicial

Contras

  • Salta trabalho de renderização, não nós de DOM: todos os nós continuam no DOM, pelo que não aguenta dezenas de milhares de linhas
  • Precisa de contain-intrinsic-size (auto <comprimento>) por item, senão os itens saltados colapsam e a barra de scroll salta

6. Sem virtualização: paginação ou “carregar mais”

Seção intitulada “6. Sem virtualização: paginação ou “carregar mais””

Prós: o mais simples; todas as linhas são DOM real, pelo que a pesquisa na página, os leitores de ecrã e os links funcionam; funciona no servidor sem alterações. Contras: sem scroll contínuo; fraco para leitura densa (logs, feeds, grids ao estilo folha de cálculo).

O TanStack Virtual é o padrão da empresa para virtualização, e o último degrau de uma escada curta: paginar ou filtrar primeiro, depois CSS, depois o Virtual. É também o padrão para tabelas virtualizadas (ADR-0018).

Em concreto:

  • A escada. 1) Se o utilizador não precisa de todas as linhas no ecrã, paginar ou filtrar no servidor (ADR-0018, ADR-0012). 2) Para conteúdo estático longo e dentro do DOM em que a pesquisa na página importa, usar content-visibility: auto com contain-intrinsic-size: auto <px>. 3) Quando o tamanho do DOM é o problema medido — milhares de linhas interativas, streams, grids densas — usar o TanStack Virtual. Medir antes de subir
  • Pacote: apenas @tanstack/react-virtual. Fixa o seu @tanstack/virtual-core exato; nunca instalar o core em separado, e não esperar que os dois números de versão coincidam
  • Um hook partilhado por app (src/components/virtual/) envolve o useVirtualizer, para que as predefinições da casa vivam num só sítio. Os componentes que renderizam as linhas continuam apresentacionais: recebem os itens virtuais e renderizam. Manter o overscan na predefinição de 1 até o profiling mostrar linhas em branco durante o scroll, e só então aumentá-lo. Se o React 19 registar o aviso de flushSync, definir useFlushSync: false nesse hook
  • Um só elemento de scroll com altura fixa (overflow-auto; min-h-0 dentro de flex). Usar useWindowVirtualizer apenas para listas ao nível da página, com scrollMargin para o que estiver acima da lista. As linhas ficam posicionadas de forma absoluta num contentor relative com o tamanho de getTotalSize(), como nos exemplos do TanStack
  • Chaves estáveis, alturas fixas. getItemKey devolve o id do registo, nunca o índice, e é memoizado com useCallback. Preferir alturas de linha fixas; acrescentar data-index e ref={virtualizer.measureElement} apenas às linhas que variam mesmo. Nos exemplos do TanStack Table, a medição dinâmica de linhas é saltada no Firefox, que mede as bordas das tabelas de forma diferente
  • SSR: passar initialRect com a altura fixa do contentor para que as primeiras linhas estejam no HTML, e initialOffset ao restaurar uma posição. Quando a altura não é conhecida à partida, renderizar um skeleton até useHydrated() (ADR-0013). Nunca lançar uma lista virtual cujo HTML do servidor seja vazio
  • Dados: uma lista virtual sobre dados remotos carrega páginas com useInfiniteQuery (ADR-0012) e vai buscar a página seguinte quando o último índice virtual se aproxima do número de itens carregados. A ordenação e a filtragem ficam no servidor; nunca as misturar com processamento no cliente sobre páginas carregadas (ADR-0018). O chat e o output de IA em streaming usam o modo ancorado ao fim (anchorTo: "end", followOnAppend; ver ADR-0025)
  • Com o TanStack Table: count é table.getRowModel().rows.length; renderizar virtualizer.getVirtualItems(), nunca rows.map(), e ler rows[virtualRow.index]. As tabelas seguem o layout do TanStack: display: grid em table e thead, display: flex em tr, células dimensionadas por column.getSize() e um thead sticky. A nossa cópia do Table do shadcn/ui envolve a <table> num div com overflow-x-auto, que é ele próprio um contentor de scroll: nas tabelas virtualizadas, dar a esse wrapper a altura fixa e usá-lo como elemento de scroll (um containerRef e um containerClassName no nosso Table), senão o cabeçalho sticky fica preso ao wrapper em vez de ao nosso scroller
  • A semântica é nossa:
    • As tabelas continuam a ser tabelas a sério. Os browsers mantêm a semântica de tabela com display: grid desde 2023 (display: contents é a exceção); testar com um leitor de ecrã na mesma. Pôr aria-rowcount (cabeçalho incluído) na table e aria-rowindex em cada linha renderizada, cabeçalho = 1, porque faltam linhas no DOM
    • As listas são ul/li (ou role="list" / "listitem") com aria-setsize e aria-posinset em cada item, como o ARIA exige quando só parte de um conjunto está no DOM
  • O foco sobrevive ao scroll. Um rangeExtractor à medida acrescenta o índice com foco ou ativo ao resultado do defaultRangeExtractor, para que essa linha nunca seja desmontada. Sem isso, fazer scroll para longe perde o foco, e um tabindex="0" móvel (roving) que seja desmontado deixa a lista sem ponto de tabulação
  • Teclado: um só ponto de tabulação por lista. Usar roving tabindex (o browser faz scroll do elemento com foco para a vista) ou aria-activedescendant (o item ativo tem de continuar a ser descendente no DOM, daí a regra anterior). As setas, Page Up/Down, Home e End chamam scrollToIndex() e depois dão foco à linha quando ela montar; chamar cancelScroll() quando o utilizador usa a roda, toca ou prime uma tecla a meio do scroll. Uma tabela só de leitura mantém a semântica nativa (todos os controlos focáveis são pontos de tabulação); role="grid" e o seu modelo de setas só para grids interativas ou editáveis (ARIA Authoring Practices). Os atalhos ao nível da linha passam pelo ADR-0024
  • Sem pesquisa na página. As linhas fora do ecrã não se encontram com Ctrl+F; dar a toda a lista virtualizada um controlo de pesquisa ou de filtro
  • Testes: os testes de componentes em browser mode (ADR-0014) verificam que as linhas esperadas renderizam, que scrollToIndex monta o alvo e que o foco sobrevive a um scroll; o axe corre nas páginas com listas virtuais

Os fatores decisivos: headless, pelo que o nosso markup e os nossos tokens ficam; a biblioteca que o TanStack Table documenta para grids grandes; um só ciclo de lançamentos com o Router, o Query e o Table; e as partes difíceis (tamanhos dinâmicos, hooks de SSR, feeds ancorados ao fim) já construídas. Aceitamos que a acessibilidade, o foco e o tratamento de teclado são nossos, e que a virtualização é um último recurso e não uma predefinição.

Mais fácil:

  • Uma só API de virtualização para listas, tabelas, grids e feeds, com markup que continua a ser shadcn/ui
  • As tabelas grandes combinam com o padrão documentado do Table; as vistas de chat e de logs usam um modo ancorado ao fim já incluído
  • Um hook partilhado e componentes de linha partilhados tornam as regras de acessibilidade (contagens, índices, foco) a predefinição, não uma tarefa por ecrã

Mais difícil:

  • A acessibilidade é nossa: aria-rowcount / aria-rowindex ou aria-setsize / aria-posinset, foco mantido montado e tratamento de teclado têm de ser construídos uma vez e revistos. Nada disto vem de borla da biblioteca
  • Sem pesquisa na página do browser, e as linhas virtualizadas são invisíveis às tecnologias de apoio até entrarem por scroll; um controlo de pesquisa ou de filtro é o substituto
  • O layout é mais frágil do que uma lista simples: um scroller de altura fixa, posicionamento absoluto, um layout de grid à medida para tabelas e a alteração ao wrapper do Table
  • As alturas dinâmicas custam precisão de scroll; as alturas fixas custam liberdade de design
  • Sem initialRect, o primeiro HTML é vazio; cada lista virtual precisa da sua história de SSR
  • Os testes precisam de um browser real e de layout real; o react-virtual e o virtual-core são atualizados em conjunto, com dois números de versão

Do que abdicamos: dos roles ARIA integrados e das props de SSR bem arrumadas do react-window, do dimensionamento dinâmico sem configuração do react-virtuoso e do virtua, e dos componentes prontos de grupos, grid e tabela do Virtuoso. Aceite — uma só API headless que o TanStack Table documenta pesa mais numa predefinição da empresa; um produto que precise de uma lista de mensagens de nível chat pode ponderar um componente comercial no seu próprio ADR.