Destino Dev

Blog

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.

  1. Documentação oficial do Vue 3 (composition API) — leia seções sobre reatividade e lifecycle.
  2. Pinia docs — patterns para stores com actions assíncronas.
  3. MDN IntersectionObserver — parâmetros e pitfalls.
  4. 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