Scroll infinito reativo no Vue 3 com Composition API e Pinia
Por John Pires · 12 de março de 2026

Por que aprender isso em 2026
Interfaces com carregamento contínuo de conteúdo viraram padrão em muitos sites e apps. Em 2026, a expectativa do usuário por fluidez é maior: redes móveis são melhores, padrões de UX buscam reduzir cliques e frameworks evoluíram para ferramentas reativas mais eficientes. Aprender a construir um scroll infinito reativo com Vue 3 e Pinia significa entender como juntar reatividade, gerenciamento de estado e APIs do browser (IntersectionObserver) de forma performática.
Analogia direta: imagine um policial na entrada de uma festa que deixa entrar mais pessoas somente quando a pista fica vazia. No scroll infinito, o 'policial' é o IntersectionObserver; o 'controle de entrada' é o estado que guarda o próximo lote de itens; o 'balcão de checagem' é o componente que dispara carregamentos.
O problema que resolvemos
Muitos iniciantes implementam scroll infinito com evento 'scroll' no window, e dentro dele fazem requisições sem controle. Resultado: múltiplas chamadas, jitter, estado inconsistente e bugs ao trocar rotas. A combinação Composition API + Pinia permite separar responsabilidade: lógica de carregamento num composable, estado global/local no store, e a view apenas reage a mudanças.
Alternativas e porque escolher a proposta
Alternativas: usar um pacote pronto (vue-infinite-loading), usar evento de scroll manual, ou usar IntersectionObserver. Pacotes prontos aceleram, mas escondem detalhes e podem ser pesados. Evento scroll exige throttle/debounce e tem problemas de performance. IntersectionObserver é a solução moderna: eficiente, nativa e simples quando combinada com reatividade do Vue.
Configuração do ambiente
Vamos montar um ambiente mínimo que facilita reproduzir os exemplos. Presumo Node 18+ e npm/yarn. Usaremos Vue 3 (create-vue), Pinia, e um servidor fake (jsonplaceholder) para mostrar chamadas reais. Para iniciantes, o passo a passo é o melhor caminho.
Passo a passo rápido
1) Criar projeto: execute 'npm init vue@latest' e escolha 'Vue 3', 'TypeScript' opcional, e 'Pinia' se disponível. 2) Instalar Pinia: 'npm install pinia'. 3) Estrutura mínima: src/main.js, src/App.vue, src/stores/usePosts.js, src/components/InfiniteList.vue.
Abaixo um exemplo de main.js que registra Pinia e monta a app. Antes do bloco, explico o que faz: registra o plugin Pinia no app Vue. Depois do bloco, explico como isso integra com os componentes.
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
Quando executar, Pinia passa a estar disponível via useStore em qualquer componente, mantendo estado reativo compartilhável. Isso evita prop drilling e dá controle centralizado para carregar páginas e gerenciar cache.
Dependências e ferramenta opcional
Recomendo instalar também a extensão Vue Devtools e Pinia devtools. Elas ajudam a inspeccionar estado e ver mutações em tempo real — valioso para iniciantes entenderem fluxo reativo.
Hello World progressivo
Aqui vamos construir um exemplo mínimo: carregar posts em lotes de 10 usando jsonplaceholder.typicode.com e disparar carregamento quando um sentinel (elemento vazio no final da lista) aparece na viewport via IntersectionObserver.
Store com Pinia (o que faz)
O store guarda itens carregados, página atual, estado de carregamento e função para buscar o próximo lote. Mantemos responsabilidade de fetch fora do componente, facilitando testes e reuso.
// src/stores/usePosts.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const usePosts = defineStore('posts', () => {
const items = ref([])
const page = ref(1)
const perPage = 10
const loading = ref(false)
const endReached = ref(false)
async function fetchNext() {
if (loading.value || endReached.value) return
loading.value = true
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts?_page=${page.value}&_limit=${perPage}`)
const data = await res.json()
if (data.length < perPage) endReached.value = true
items.value.push(...data)
page.value++
} catch (e) {
console.error('fetchNext error', e)
} finally {
loading.value = false
}
}
function reset() {
items.value = []
page.value = 1
endReached.value = false
}
return { items, page, loading, endReached, fetchNext, reset }
})
Depois de executar esse store, chamar fetchNext() carrega um lote de posts e atualiza items. O store expõe reset para reiniciar a sequência ao trocar de rota.
Componente reativo com IntersectionObserver
O componente monta um sentinel no final da lista; quando o sentinel entra na viewport, chamamos fetchNext do store. Contexto: o componente só preocupa-se com a renderização e o observer; a lógica de fetch e estado está no store.
// src/components/InfiniteList.vue
<template>
<div class='list'>
<article v-for='post in posts.items' :key='post.id' class='item'>
<h4>{{ post.title }}</h4>
<p>{{ post.body }}</p>
</article>
<div ref='sentinel' class='sentinel'>
<p v-if='posts.loading'>Carregando...</p>
<p v-else-if='posts.endReached'>Fim dos itens</p>
</div>
</div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { usePosts } from '../stores/usePosts'
const posts = usePosts()
const sentinel = ref(null)
let observer = null
onMounted(() => {
// garante que ao montar temos o primeiro carregamento
if (posts.items.length === 0) posts.fetchNext()
observer = new IntersectionObserver(entries => {
const entry = entries[0]
if (entry.isIntersecting) {
posts.fetchNext()
}
}, { root: null, rootMargin: '200px' })
if (sentinel.value) observer.observe(sentinel.value)
})
onBeforeUnmount(() => {
if (observer && sentinel.value) observer.unobserve(sentinel.value)
})
</script>
<style scoped>
.list { max-width: 600px; margin: 0 auto; }
.item { padding: 12px; border-bottom: 1px solid #ddd; }
.sentinel { height: 60px; display: flex; align-items: center; justify-content: center; }
</style>
Executando, você verá itens sendo carregados automaticamente quando o sentinel aparece. O rootMargin amplia a área de disparo para evitar carregamentos tardios.
Mini-projeto prático
Neste mini-projeto vamos juntar tudo: rota com busca, store compartilhado, debounce na busca e tratamento de erros. Objetivo: criar uma lista filtrável com scroll infinito reativo.
Estrutura do projeto
Arquivos-chave: src/main.js, src/stores/usePosts.js (já criado), src/components/InfiniteList.vue (base) e src/pages/SearchPage.vue que integra busca + reset do store.
Vamos ver um trecho do componente de busca que usa um debounced input. Antes do código: o que faz? Debounce evita chamadas excessivas ao alterar o texto. Depois do código: descrevo o que acontece ao digitar.
// src/pages/SearchPage.vue
<template>
<div>
<input v-model='query' placeholder='Buscar título...' />
<InfiniteList :filter='query' />
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import InfiniteList from '../components/InfiniteList.vue'
const query = ref('')
// aqui poderia haver lógica para armazenar localmente a query
watch(query, (v) => {
// por enquanto apenas reativo; o InfiniteList pode observar a prop e resetar
})
</script>
No exemplo acima, InfiniteList receberia a prop filter e chamaria posts.reset() + fetchNext com o filtro aplicado. Isso demonstra separação: página controla UI de busca, lista controla dados e scroll.
Adaptação do store para filtro
Para suportar filtro, altere fetchNext para aceitar um parâmetro de query e incluir iteração no endpoint. Isso exemplifica encapsulamento: apenas o store precisa saber como montar a URL.
Erros comuns e dicas práticas
Listo problemas frequentes e soluções rápidas. Leia cada item atentamente; eles poupam horas de debug.
- Múltiplas chamadas simultâneas: sem bloquear com loading, o observer pode disparar várias vezes. Solução: checar loading antes de chamar fetchNext.
- Perda de observador ao trocar de rota: se o componente for destruído, sempre limpar o observer em onBeforeUnmount.
- Memória e leaks: não manter referências globais a observers; limpar sempre.
- Problemas com placeholders e layout shifts: reserve espaço para itens futuros com skeletons para evitar que o sentinel se mova antes do carregamento.
Dica: usar rootMargin positivo (ex: '200px') faz o carregamento acontecer antes do usuário chegar ao fundo, evitando pausas visíveis.
Outra armadilha: confiar em IDs da API que não são únicos ao concatenar listas; sempre use keys únicas no v-for para evitar re-render desnecessário.
Recursos para continuar estudando
Depois de implementar o básico, é hora de aprofundar: teste unitários para stores, SSR e otimizações. Listei recursos práticos e objetivos.
- Documentação oficial do Vue 3 (composition API) — leia seções sobre reatividade e lifecycle.
- Pinia docs — patterns para stores com actions assíncronas.
- MDN IntersectionObserver — parâmetros e pitfalls.
- Artigos sobre performance de listas virtuais (vue-virtual-scroller) para casos com milhares de itens.
Recomendo experimentar virtual scrolling quando a lista cresce demais; combinar virtual scroller e infinite loading é padrão em apps de alto desempenho.
Conclusão prática
O padrão apresentado junta três forças: Vue 3 (reatividade fina), Pinia (estado previsível) e IntersectionObserver (disparo eficiente). Para iniciantes, é um ótimo exercício: cobre ciclo de vida, stores, fetch assíncrono e DOM API moderna.
Resumo de passos práticos para copiar agora:
- Configurar Pinia em main.js.
- Criar store com fetchNext, loading e endReached.
- Construir componente que observe um sentinel com IntersectionObserver.
- Tratar loading, limpar observer em onBeforeUnmount e usar rootMargin.
Implementar esses pontos dá uma base sólida para features mais avançadas: filtro com debounce, cache de páginas, e integração com rotas. Depois de dominar o padrão, tente transformar o store para suportar revalidação e cache por rota.
Se algo falhar, volte aos blocos pequenos: isole o fetch no store e use console.log para entender quando observer dispara. Debug reativo é questão de observar valores e lifecycle.
Boa prática final: escreva testes para o store (mock do fetch) e testes de integração para o componente onde você simula IntersectionObserver para garantir estabilidade durante mudanças.
Assuntos: Vue.js, pinia, composition-api, infinite-scroll, reactivity
Leia também
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…
Streaming SSR em Nuxt 3: server routes Nitro com SSE e NDJSON e consumo no cliente
Como implementar streaming eficiente em Nuxt 3 usando Nitro: compare o anti-padrão de acumular tudo com as rotas SSE/NDJSON corretas, composables cliente…
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…
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…
Virtualização reativa em Vue 3: Composition API + Pinia para listas gigantes
Prático e direto: como substituir o anti-pattern de renderizar tudo por uma virtualização reativa em Vue 3 usando Composition API e Pinia, com padrões…
Evitar re-renders no Vue 3: shallowRef, markRaw e storeToRefs (Pinia)
Guia prático para reduzir re-renders no Vue 3: quando usar shallowRef, markRaw e storeToRefs (Pinia). Padrões, exemplos funcionais e armadilhas para melhorar…