Destino Dev

Blog

Tabela paginada reativa em Vue 3: Composition API e Pinia passo a passo

Por John Pires · 12 de março de 2026

Tabela paginada reativa em Vue 3: Composition API + Pinia

O que é e por que importa

Uma tabela paginada reativa é um componente que exibe um conjunto de dados divididos em páginas e atualiza automaticamente sua exibição quando o estado muda. Em aplicações modernas isso importa porque dados volumosos não devem ser renderizados de uma vez; paginar melhora desempenho, experiência do usuário e reduz custos de rede. Além disso, quando a tabela é reativa e gerenciada por um estado global (Pinia), facilita compartilhamento entre componentes e mantém a lógica previsível.

Para iniciantes: imagine uma prateleira de livros com divisórias. Cada divisória é uma página de resultados. Você não precisa abrir toda a estante para pegar um livro — só a divisória atual. O Vue e o Pinia cuidam de abrir a prateleira certa e avisar componentes quando trocar de divisória.

Neste artigo você verá conceitos do zero, analogias simples, um passo a passo com código comentado e um exemplo prático completo que pode ser copiado e executado em um projeto Vue 3.

O problema que resolvemos

Sem paginação, uma tabela pode tentar renderizar milhares de linhas, travando o navegador. Sem gestão de estado central, múltiplos componentes podem duplicar a lógica de paginação. A solução: um componente de tabela simples + uma store Pinia que controla página atual, tamanho da página, filtros e busca.

Quando usar client-side vs server-side

Existem dois cenários: dados já carregados no cliente (client-side pagination) ou dados paginados pela API (server-side). Para iniciantes focaremos em um padrão híbrido: uma store que pode trabalhar com ambos, com um mock de API que retorna páginas. O ajuste para chamadas reais é trivial.

Conceitos fundamentais com analogias

Antes de codificar, precisamos entender alguns conceitos do Vue 3, Composition API e Pinia. Vou explicar com analogias e depois mostrar como aplicá-los.

Reatividade (analogia: plantas e regadores)

Reatividade no Vue é como ter plantas em vasos que reagem quando você rega: se um valor mudar (você rega), qualquer template ou função que dependa desse valor será automaticamente atualizado (a planta cresce). No Composition API usamos ref e reactive para declarar esses valores reativos.

Exemplo prático: ref para um contador simples, reactive para um objeto complexo com filtros. Os componentes que usam esses valores atualizam instantaneamente.

Pinia (analogia: armário de ferramentas)

Pinia é o armário onde guardamos ferramentas compartilhadas entre componentes. Em vez de cada componente ter sua própria régua, todos pegam a régua do armário. A store centraliza estado, getters e ações assíncronas (como buscar a página X da API).

Vantagens: teste mais fácil, lógica consolidada, composição com Composition API e tipagem se usar TypeScript.

Passo a passo com código comentado

Vamos montar um projeto mínimo com Vue 3 e Pinia. Abaixo estão três blocos de código principais: a store Pinia, um mock de API interno e o componente de tabela paginada. Cada bloco tem contexto antes e explicação depois.

1) Configuração do Pinia e main.js

Contexto: arquivo de inicialização da aplicação. Aqui criamos a instância do Vue, registramos o Pinia e montamos o app.

// 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')

O que isso faz: inicializa o Pinia para que qualquer componente possa usar stores. Depois montamos a aplicação.

2) Store Pinia: controle de paginação

Contexto: a store contém estado reativo da tabela, getters e ações para carregar páginas. Usamos um mock de API (função fetchPage) para simular chamadas. A store expõe ações: setPage, setPageSize, fetchPage.

// stores/useTableStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// Mock simples de dados: 100 itens
const ITEMS = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, name: 'Item ' + (i + 1) }))

// Função que simula uma API que retorna uma página
function fetchPageFromMock(page, pageSize, delay = 200) {
  const start = (page - 1) * pageSize
  const end = start + pageSize
  const items = ITEMS.slice(start, end)
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ items, total: ITEMS.length })
    }, delay)
  })
}

export const useTableStore = defineStore('table', () => {
  const page = ref(1)
  const pageSize = ref(10)
  const items = ref([])
  const total = ref(0)
  const loading = ref(false)
  const error = ref(null)

  const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))

  async function fetchPage(p = page.value) {
    loading.value = true
    error.value = null
    try {
      const res = await fetchPageFromMock(p, pageSize.value)
      items.value = res.items
      total.value = res.total
      page.value = p
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  }

  function setPage(p) {
    const clamped = Math.min(Math.max(1, p), totalPages.value)
    if (clamped !== page.value) fetchPage(clamped)
  }

  function setPageSize(size) {
    pageSize.value = size
    // ao mudar pageSize, volte para primeira página por previsibilidade
    fetchPage(1)
  }

  return { page, pageSize, items, total, loading, error, totalPages, fetchPage, setPage, setPageSize }
})

O que acontece: a store mantém estado centralizado. fetchPageFromMock simula latência. A store expõe funciones para alterar página e tamanho da página. Componentes usarão essas funções para mudar a exibição.

3) Componente: TabelaPaginated.vue (template + script)

Contexto: componente que consome a store e renderiza a tabela, controles de paginação e feedback de carregamento.

// components/TabelaPaginated.vue
<template>
  <div>
    <div style='display:flex;align-items:center;gap:8px;margin-bottom:12px;'>
      <label>Linhas por página:
        <select v-model='localPageSize'>
          <option v-for='n in [5,10,20,50]' :key='n' :value='n'>{{ n }}</option>
        </select>
      </label>
      <div v-if='loading' style='color:#007bff'>Carregando...</div>
      <div v-if='error' style='color:#c00'>Erro: {{ error.message || error }}</div>
      <div style='margin-left:auto'>Total: {{ total }}</div>
    </div>

    <table border='1' cellpadding='6' cellspacing='0' style='width:100%;border-collapse:collapse'>
      <thead>
        <tr>
          <th>ID</th>
          <th>Nome</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for='row in items' :key='row.id'>
          <td>{{ row.id }}</td>
          <td>{{ row.name }}</td>
        </tr>
      </tbody>
    </table>

    <div style='display:flex;align-items:center;gap:6px;margin-top:12px;'>
      <button @click='prev' :disabled='page <= 1'>Anterior</button>
      <span>Página {{ page }} / {{ totalPages }}</span>
      <button @click='next' :disabled='page >= totalPages'>Próxima</button>
    </div>
  </div>
</template>

<script setup>
import { ref, watch, onMounted } from 'vue'
import { useTableStore } from '../stores/useTableStore'

const store = useTableStore()
const { items, page, pageSize, total, loading, error, totalPages, fetchPage, setPage, setPageSize } = store

// bind local select com debounce simples
const localPageSize = ref(pageSize)

watch(localPageSize, (v) => {
  setPageSize(v)
})

function prev() { setPage(page - 1) }
function next() { setPage(page + 1) }

onMounted(() => {
  // Carrega a primeira página automaticamente
  if (items.length === 0) fetchPage(1)
})
</script>

O que acontece: o componente lê o estado da store, renderiza os items e controla paginação via setPage. Mudanças em localPageSize acionam setPageSize que recarrega a store.

Exemplo prático completo

Aqui organizo os arquivos mínimos para você copiar para um projeto Vite+Vue 3. Estrutura sugerida:

  1. src/main.js
  2. src/stores/useTableStore.js
  3. src/components/TabelaPaginated.vue
  4. src/App.vue (apenas monta o componente)

Arquivo App.vue simples para testar o componente:

// App.vue
<template>
  <div style='padding:16px'>
    <h2>Exemplo: Tabela Paginada</h2>
    <TabelaPaginated />
  </div>
</template>

<script setup>
import TabelaPaginated from './components/TabelaPaginated.vue'
</script>

Instruções práticas para rodar localmente:

  • Crie um projeto com npm init vite@latest e escolha vue
  • Instale dependências: npm install e npm install pinia
  • Copie os arquivos acima e rode npm run dev

Resultado: uma tabela com paginação funcional usando Pinia e Composition API. A store abstrai fetch e estado; o componente é apenas uma camada de exibição e controle.

Como adaptar para uma API real

Troque fetchPageFromMock por fetch('/api/items?page=' + page + '&size=' + pageSize) e trate headers para total se necessário. A store permanece praticamente igual.

Performance e UX

Use loading para bloquear UI, debounce em buscas, e cache de páginas se precisar evitar refetch constantes. Para tabelas grandes, avalie virtual scrolling em vez de paginação se a navegação for por posição contínua.

Erros comuns, armadilhas e dicas práticas

Essa seção lista problemas que iniciantes enfrentam e como evitá-los.

Erro: manipular estado local e global duplicadamente

Muitos iniciantes mantêm page tanto no componente quanto na store e ficam fora de sincronia. Regra prática: uma única fonte de verdade. Deixe page na store; componentes só chamam ações.

Erro: não clamar limites de página

Se você pedir page 0 ou page > totalPages, a API pode responder vazio ou com erro. Sempre normalize a página usando Math.min/Math.max na store antes de fetch.

Dica: centralize validação e fetch na store. Componentes só disparam ações; isso reduz bugs de sincronização.

Erro: esquecer indicador de carregamento

Sem loading, usuários clicam em botões repetidamente e disparam múltiplas requests. Marque loading=true enquanto a requisição estiver em progresso e desabilite controles relevantes.

Outras armadilhas

  • Não faça lógica de paginação no template — coloque na store ou em composables.
  • Se usar watch com deep objects, prefira watchers específicos para evitar reruns caros.
  • Ao usar APIs reais, trate falhas de rede e timeouts.

Próximos passos para aprofundar

Depois de dominar esse padrão básico, siga para tópicos que elevarão sua tabela paginada a produção:

  1. Server-side pagination com cursor-based pagination (mais eficiente que offset para grandes datasets)
  2. Composables reutilizáveis para lógica de paginação e busca (ex: usePagination)
  3. Virtual scrolling para renderizar somente linhas visíveis
  4. Cache por página e invalidation inteligente
  5. Testes unitários da store com mocking de fetch

Sugestão técnica: crie um composable usePagedFetch que encapsula fetch, cache, retries e expõe uma interface consistente para a store ou componentes.

Conclusão prática

Resumo direto: use Pinia como fonte única de verdade para estado de paginação; mantenha componentes focados em renderização e controles; trate loading, erros e normalização de página na store. Esse padrão evita duplicação e facilita evolução do componente.

Passos concretos para aplicar agora:

  1. Implemente a store com fetch centralizado (como no exemplo).
  2. Crie um componente leve que injete a store e apenas exponha controles e template.
  3. Adapte fetch para a API real e adicione tratamento de erros e caches simples.

Com isso você terá uma tabela paginada reativa, fácil de testar, estender e integrar em aplicações reais. Evite misturar responsabilidades; mantenha o store responsável por estado e fetch. Boas práticas simples garantem código robusto e manutenção tranquila.

Assuntos: Vue.js, pinia, composition-api, pagination, reactivity, components

Leia também