Destino Dev

Blog

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.

  1. Use filtros incrementais ou índices invertidos quando possível.
  2. Evite substituir arrays por novos objetos sem necessidade: splice vs 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