Virtualização reativa em Vue 3: Composition API + Pinia para listas gigantes
Por John Pires · 11 de março de 2026

Virtualização reativa em Vue 3: Composition API + Pinia para listas gigantes
Contexto e motivacao
Listas grandes são um dos problemas práticos mais comuns em UIs modernas. Renderizar 5k, 10k ou 50k itens diretamente com v-for leva a frames longos, GC frequente e UI travando. A virtualização (windowing) resolve isso renderizando apenas o subconjunto visível. Este artigo mostra, no formato de code review, por que implementações óbvias falham e como construir uma virtualização reativa robusta usando Vue 3 (Composition API) e Pinia.
Porque a virtualizacao importa
O objetivo não é apenas reduzir o número de nós DOM: é reduzir trabalho de reconciliamento, evitar recalculos pesados (layout/paint) e manter reatividade previsível. Em aplicações reais, virtualização interage com seleção, filtros, ordenação, e atualizações incrementais — ou seja, precisa ser integrada ao fluxo de estado (Pinia) e manter invariantes reativos.
O que vamos abordar
Vou mostrar um exemplo intencionalmente ruim (anti-pattern), depois explicar seus problemas, e por fim construir uma solução recomendada. Cobrirá: cálculo de janela, mapeamento entre índice e deslocamento, cache de alturas (opcional), integração com Pinia para estado persistente, e performance tips como throttle, passive listeners e evitar watchers desnecessários.
Como funciona por baixo dos panos
Virtualização básica é simples conceptualmente: calcular startIndex e endIndex com base no scrollTop e na altura da viewport, e renderizar apenas items[start:end]. O detalhe que quebra muitas implementações são variações de altura, inserções/remover no meio da lista, e sincronização reativa com estado global.
Elementos essenciais
Existem três peças essenciais: o viewport (elemento rolável), o calço (spacer) que cria a altura total do scroll, e o container dos itens visíveis posicionados com transform/translate. A posição de cada item pode ser calculada por índice quando a altura é fixa — para alturas variáveis precisamos de um índice->offset map (cache) e/ou medição dinâmica.
Problemas comuns no "jeito fácil"
O anti-pattern mais comum é tentar usar v-for diretamente em toda a lista e confiar que o navegador vai ser inteligente. Outro erro: recalcular todos os offsets em cada scroll, ou criar watchers que dispararem renderizações completas. Esses causam jank e memórias temporárias.
Dica: quando possível, prefira layouts com altura fixa por item. O custo de suportar alturas variáveis é justificável apenas para UIs complexas.
Implementacao passo a passo
Vamos ao code review. Primeiro o exemplo ruim — pequeno, direto, demonstra o problema. Depois a refatoração para uma virtualização reativa integrada ao Pinia.
Anti-pattern: renderizar tudo
Contexto: este componente recebe 20k itens e renderiza todos. Funciona funcionalmente, mas trava a UI. Observe o uso de computed para filtros que reexecutam sobre tudo.
<template>
<div class="list">
<div v-for="item in items" :key="item.id" class="row">
{{ item.text }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useStore } from '@/stores/listStore'
const store = useStore()
const items = computed(() => store.filtered)
</script>
O que ocorre depois: o DOM cria 20k nós, tempo de montagem alto e frames longos. O filtro computado reitera toda a coleção sempre que qualquer dependência muda. Em dispositivos fracos, a aplicação fica inutilizável.
Refatoracao: virtualizacao basica (altura fixa)
Contexto: versão mínima viável que resolve a maioria dos casos de listas uniformes. Calcula start/end com base no scroll. Usa transform: translateY para posicionar o container visível, reduzindo reflows.
<template>
<div ref="viewport" class="viewport" @scroll="onScroll">
<div :style="{ height: totalHeight + 'px' }">
<div :style="{ transform: 'translateY(' + topOffset + 'px)' }" class="inner">
<div v-for="item in visible" :key="item.id" class="row">
{{ item.text }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useStore } from '@/stores/listStore'
const ITEM_HEIGHT = 40
const VISIBLE_COUNT = 20
const viewport = ref(null)
const scrollTop = ref(0)
const store = useStore()
const totalHeight = computed(() => store.items.length * ITEM_HEIGHT)
const startIndex = computed(() => Math.floor(scrollTop.value / ITEM_HEIGHT))
const endIndex = computed(() => Math.min(store.items.length, startIndex.value + VISIBLE_COUNT))
const visible = computed(() => store.items.slice(startIndex.value, endIndex.value))
const topOffset = computed(() => startIndex.value * ITEM_HEIGHT)
function onScroll(e) {
scrollTop.value = e.target.scrollTop
}
onMounted(() => { /* medidas, listeners adicionais se necessario */ })
</script>
O que acontece depois: o DOM contém apenas ~20 nós visíveis mais o espaço vazio. Scroll é fluido; o custo de reconciliation cai verticalmente. Isso já é suficiente para centenas de milhares de itens em muitos casos.
Integração com Pinia e reatividade correta
Contexto: precisamos que o estado (filtros, seleção, atualizações de itens) viva no Pinia. A virtualização deve reagir a mudanças incrementais sem re-renderizar tudo. A chave é trabalhar com referências imutáveis ou operações que não invalidem todo o array e usar slice e splice de forma controlada.
// stores/listStore.js
import { defineStore } from 'pinia'
import { reactive } from 'vue'
export const useListStore = defineStore('list', () => {
const state = reactive({ items: [] })
function setItems(newItems) {
state.items = newItems
}
function updateItem(id, patch) {
const idx = state.items.findIndex(i => i.id === id)
if (idx >= 0) state.items.splice(idx, 1, { ...state.items[idx], ...patch })
}
return { state, setItems, updateItem }
})
O que acontece depois: usando Pinia você centraliza atualizações. Note que a substituição completa de state.items ainda causa recomputes, então prefira atualizações localizadas para evitar invalidar caches da virtualização.
Patterns e boas praticas
Depois da implementação básica, surgem padrões que reduzem bugs e melhoram performance: debounce/throttle do scroll, memorização de offsets, passive listeners, e técnicas para evitar recalcs desnecessários.
Estrutura recomendada do componente
Recomendo separar responsabilidades: um componente VirtualList genérico (visual + lógica de janela) e um componente consumidor que fornece render functions ou slots para cada item. Isso permite reutilização e testes.
Exemplo de API do componente: props para items, itemHeight (ou função), overscan, e emits para reachStart/reachEnd para carregamento incremental.
Cache de alturas e medidas dinamicas
Para alturas variáveis, mantenha um map de alturas por índice e um prefix-sum array (offsets). Ao renderizar um item pela primeira vez, meça sua altura e atualize o cache. Isso evita medir todos os itens.
// trecho: gerencia offsets
import { ref } from 'vue'
const heights = ref(new Map())
const offsets = ref([]) // prefix sum
function setHeight(index, h) {
heights.value.set(index, h)
// recompute offsets incrementalmente; evite recalcular tudo quando possivel
}
O que acontece depois: com medidas on-demand você ganha suporte a alturas variáveis sem pagar o custo de medições iniciais em massa.
Armadilhas comuns e como evitar
Aqui sigo no tom de code review: enumero erros que vejo frequentemente e mostro correções práticas e diretas.
1) Re-render total por usar um computed que depende do array inteiro
Problema: computed que retorna items.filter(...) ou items.map(...) reexecuta sobre toda a lista sempre. Correção: mantenha índices primários no store e aplique filtros no nível da exibição com algoritmos que possam operar em streaming ou indexes prefiltrados.
- Use filtros incrementais ou índices invertidos quando possível.
- Evite substituir arrays por novos objetos sem necessidade:
splicevs atribuição.
2) Escutar scroll sem throttle/passive
Problema: evento scroll pode disparar dezenas de vezes por frame; handlers pesados travam a UI. Correção: adicione { passive: true } quando possível e aplique throttle para atualização de estado que leva a re-renders.
Implementação sugerida:
import { throttle } from 'lodash-es'
const onScroll = throttle((e) => {
scrollTop.value = e.target.scrollTop
}, 16)
viewport.value.addEventListener('scroll', onScroll, { passive: true })
O que acontece depois: a UI passa a reagir em blocos controlados, mantendo 60fps em condições normais.
3) Usar index como key
Problema: usar índice como key combinado com operações de inserção/remover causa reuso indevido de nodes e estado interno do componente (inputs, animações). Correção: use identificadores estáveis (id) e, se necessário, mantenha um mapping de id->index separado.
4) Atualizações concorrentes e scroll position
Problema: ao inserir itens no topo, o scroll pula. Corrija calculando e restaurando a posição com base em offsets medidos, não em índices. Estratégia: antes de inserir, capture offset do primeiro item visível, insira, calcule novo offset do mesmo item e ajuste scrollTop.
Dica: para chat ou timelines com inserções no topo, bloqueie o auto-scroll até que o usuário esteja no fim ou aplique lógica de preservação baseada em itemId.
Conclusao pratica
Resumo direto: renderizar tudo é um erro de escala; virtualização básica (altura fixa) resolve a maioria dos casos com implementação simples. Para listas complexas, adicione cache de alturas, meça on-demand, e integre ao Pinia usando operações localizadas (splice/replace) para evitar invalidar toda a coleção.
Checklist prático antes de iniciar virtualização:
- Itens têm altura fixa? Use abordagem simples e rápida.
- Necessita persistir seleção/estado? Mova para Pinia e evite substituir arrays por novas referências desnecessárias.
- Há inserções/remocoes frequentes no topo? Implemente preservação de scroll por offset.
Implementar virtualização reativa é mais sobre controlar a superfície de reatividade do que sobre truques de DOM. Reduza dependências no topo da árvore reativa, meça apenas quando necessário e mantenha o store como fonte de verdade para dados.
Se for para adotar uma biblioteca pronta, avalie: vue-virtual-scroller e vue-virtualize resolvem muitos casos, mas entender a abordagem manual ajuda a debugar e adaptar a performance para cenários específicos (animações, items dinâmicos, acessibilidade).
Aplicando essas práticas você reduz micro-jank, mantém atualizações incrementais e consegue escalar listas para dezenas ou centenas de milhares de itens em aplicações Vue 3 com Pinia.
Assuntos: Vue.js, pinia, virtualization, composition-api, Performance
Leia também
Autenticação SSR em Nuxt 3 com refresh tokens rotativos e server routes
Guia prático para implementar autenticação SSR em Nuxt 3 usando refresh tokens rotativos, cookies httpOnly e server routes. Padrões, code samples e armadilhas…
Autocomplete reativo em Vue 3 com debounce, cache local e Pinia
Tutorial prático para iniciantes: construa um autocomplete reativo em Vue 3 usando Composition API, debounce e cache local com Pinia. Mostro o jeito errado e…
Cache reativo com Pinia + IndexedDB: estratégia offline-first no Vue 3
Padrão offline-first prático: integrar Pinia e IndexedDB para um cache reativo no Vue 3. Cache imediato na UI, persistência durável, atualizações em…
Scroll infinito reativo no Vue 3 com Composition API e Pinia
Aprenda a implementar scroll infinito reativo em Vue 3 usando Composition API e Pinia: do setup ao mini-projeto, com exemplos práticos, store…
Tabela paginada reativa em Vue 3: Composition API e Pinia passo a passo
Implementação prática de uma tabela paginada reativa em Vue 3 usando Composition API e Pinia. Guia passo a passo com store, componente, mock de API, erros…
Sincronizando estado entre abas com Pinia e BroadcastChannel (Composition API)
Aprenda a sincronizar estado entre abas usando Pinia e BroadcastChannel com Composition API. Guia passo a passo para iniciantes: setup, exemplos funcionais…