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
Ligações
Contexto
Seção intitulada “Contexto”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)
Opções consideradas
Seção intitulada “Opções consideradas”1. TanStack Virtual
Seção intitulada “1. TanStack Virtual”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
ulou cartões; layouts vertical, horizontal e em grid (lanes, ou um virtualizador por eixo);useWindowVirtualizerpara 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),initialRecteinitialOffsetpara 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
estimateSizefraco 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
2. react-window
Seção intitulada “2. react-window”Componentes para listas e grids de tamanho fixo e variável. 2.4.1, MIT.
Prós
- Pequeno e focado:
ListeGrid, dimensionamento semAutoSizer, roles ARIA predefinidos, e os componentes de linha recebemariaAttributes - A melhor história de renderização no servidor do grupo:
defaultHeightdecide quantas linhas são renderizadas no servidor, comdefaultScrollOffseteinitialRowHeightsacrescentados na 2.4.0 - MIT; 3,4 milhões de downloads no npm na semana até 2026-10-18
Contras
- Em forma de componente (
rowComponentmaisrowProps), 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
FixedSizeListjá não se aplicam
3. react-virtuoso
Seção intitulada “3. react-virtuoso”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.
4. virtua
Seção intitulada “4. virtua”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.
5. CSS content-visibility: auto
Seção intitulada “5. CSS content-visibility: auto”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).
Decisão
Seção intitulada “Decisão”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: autocomcontain-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-coreexato; 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 ouseVirtualizer, 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 ooverscanna 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 deflushSync, definiruseFlushSync: falsenesse hook - Um só elemento de scroll com altura fixa (
overflow-auto;min-h-0dentro de flex). UsaruseWindowVirtualizerapenas para listas ao nível da página, comscrollMarginpara o que estiver acima da lista. As linhas ficam posicionadas de forma absoluta num contentorrelativecom o tamanho degetTotalSize(), como nos exemplos do TanStack - Chaves estáveis, alturas fixas.
getItemKeydevolve o id do registo, nunca o índice, e é memoizado comuseCallback. Preferir alturas de linha fixas; acrescentardata-indexeref={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
initialRectcom a altura fixa do contentor para que as primeiras linhas estejam no HTML, einitialOffsetao 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; renderizarvirtualizer.getVirtualItems(), nuncarows.map(), e lerrows[virtualRow.index]. As tabelas seguem o layout do TanStack:display: gridemtableethead,display: flexemtr, células dimensionadas porcolumn.getSize()e umtheadsticky. A nossa cópia doTabledo shadcn/ui envolve a<table>num div comoverflow-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 (umcontainerRefe umcontainerClassNameno nossoTable), 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: griddesde 2023 (display: contentsé a exceção); testar com um leitor de ecrã na mesma. Pôraria-rowcount(cabeçalho incluído) natableearia-rowindexem cada linha renderizada, cabeçalho = 1, porque faltam linhas no DOM - As listas são
ul/li(ourole="list"/"listitem") comaria-setsizeearia-posinsetem cada item, como o ARIA exige quando só parte de um conjunto está no DOM
- As tabelas continuam a ser tabelas a sério. Os browsers mantêm a semântica de tabela com
- O foco sobrevive ao scroll. Um
rangeExtractorà medida acrescenta o índice com foco ou ativo ao resultado dodefaultRangeExtractor, para que essa linha nunca seja desmontada. Sem isso, fazer scroll para longe perde o foco, e umtabindex="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) ouaria-activedescendant(o item ativo tem de continuar a ser descendente no DOM, daí a regra anterior). As setas, Page Up/Down, Home e End chamamscrollToIndex()e depois dão foco à linha quando ela montar; chamarcancelScroll()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
scrollToIndexmonta 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.
Consequências
Seção intitulada “Consequências”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-rowindexouaria-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-virtuale ovirtual-coresã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.

