Como Construir Paginacao com Filtros
Este tutorial mostra como construir uma lista paginada com busca e filtros, combinando queries reativas do Vue Query com estado do cliente no Pinia.
Cenario
Voce precisa de uma pagina de listagem de produtos com:
- Busca por nome do produto
- Filtro por categoria
- Paginacao (20 itens por pagina)
- UX suave ao navegar entre paginas
Passo 1 — Store para Filtros (Estado do Cliente)
Filtros sao estado do cliente — nao vem do servidor. O Pinia os gerencia.
// src/modules/products/stores/products-store.ts
import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue'
export const useProductsStore = defineStore('products', () => {
const searchQuery = ref('')
const selectedCategory = ref<string | undefined>(undefined)
const currentPage = ref(1)
const hasActiveFilters = computed(() =>
!!searchQuery.value || !!selectedCategory.value
)
function setSearch(query: string) {
searchQuery.value = query
currentPage.value = 1 // volta para a pagina 1 quando a busca muda
}
function setCategory(category: string | undefined) {
selectedCategory.value = category
currentPage.value = 1 // volta para a pagina 1 quando o filtro muda
}
function setPage(page: number) {
currentPage.value = page
}
function clearFilters() {
searchQuery.value = ''
selectedCategory.value = undefined
currentPage.value = 1
}
return {
searchQuery: readonly(searchQuery),
selectedCategory: readonly(selectedCategory),
currentPage: readonly(currentPage),
hasActiveFilters,
setSearch,
setCategory,
setPage,
clearFilters,
}
})Por que Pinia e nao apenas refs?
O Pinia persiste o estado dos filtros quando o usuario navega para outra pagina e volta. Com refs locais, o usuario perde os filtros a cada mudanca de rota.
Passo 2 — Composable com Query Reativa
O composable cria uma queryKey reativa — quando qualquer filtro muda, o Vue Query refaz a busca automaticamente.
// src/modules/products/composables/useProductsList.ts
import { computed, type MaybeRef, toValue } from 'vue'
import { useQuery, keepPreviousData } from '@tanstack/vue-query'
import { productsService } from '../services/products-service'
import { productsAdapter } from '../adapters/products-adapter'
export function useProductsList(options: {
page: MaybeRef<number>
pageSize?: MaybeRef<number>
search?: MaybeRef<string>
category?: MaybeRef<string | undefined>
}) {
const page = computed(() => toValue(options.page))
const pageSize = computed(() => toValue(options.pageSize) ?? 20)
const search = computed(() => toValue(options.search) ?? '')
const category = computed(() => toValue(options.category))
const { data, isLoading, isFetching, error } = useQuery({
queryKey: computed(() => ['products', 'list', {
page: page.value,
pageSize: pageSize.value,
search: search.value,
category: category.value,
}]),
queryFn: async () => {
const response = await productsService.list({
page: page.value,
pageSize: pageSize.value,
search: search.value,
category: category.value,
})
return productsAdapter.toProductList(response.data)
},
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
})
return {
items: computed(() => data.value?.items ?? []),
totalPages: computed(() => data.value?.totalPages ?? 0),
totalCount: computed(() => data.value?.totalCount ?? 0),
isEmpty: computed(() => !isLoading.value && (data.value?.items.length ?? 0) === 0),
isLoading,
isFetching,
error,
}
}Padroes-chave explicados
keepPreviousData — Enquanto busca a pagina 2, o usuario ainda ve os dados da pagina 1. Sem isso, a lista pisca vazia a cada mudanca de pagina.
staleTime: 5 * 60 * 1000 — Se o usuario vai para a pagina 2 e volta para a pagina 1 em menos de 5 minutos, nao refaz a busca (usa o cache).
queryKey reativa — Quando page, search ou category mudam, o Vue Query detecta uma nova key e busca automaticamente. Voce nao precisa chamar refetch().
Passo 3 — Campo de Busca com Debounce
Nao faca chamadas a API a cada tecla digitada. Use uma busca com debounce.
<!-- src/modules/products/components/ProductSearch.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const localSearch = ref(props.modelValue)
let debounceTimer: ReturnType<typeof setTimeout>
watch(localSearch, (value) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
emit('update:modelValue', value)
}, 300)
})
</script>
<template>
<input
v-model="localSearch"
type="search"
placeholder="Buscar produtos..."
/>
</template>Passo 4 — Filtro de Categoria
<!-- src/modules/products/components/CategoryFilter.vue -->
<script setup lang="ts">
defineProps<{
selected?: string
categories: Array<{ value: string; label: string }>
}>()
const emit = defineEmits<{
change: [category: string | undefined]
}>()
</script>
<template>
<div class="category-filter">
<button
:class="{ active: !selected }"
@click="emit('change', undefined)"
>
Todos
</button>
<button
v-for="cat in categories"
:key="cat.value"
:class="{ active: selected === cat.value }"
@click="emit('change', cat.value)"
>
{{ cat.label }}
</button>
</div>
</template>Passo 5 — Componente de Paginacao
<!-- src/shared/components/AppPagination.vue -->
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
currentPage: number
totalPages: number
}>()
const emit = defineEmits<{
change: [page: number]
}>()
const pages = computed(() => {
const range: number[] = []
const start = Math.max(1, props.currentPage - 2)
const end = Math.min(props.totalPages, props.currentPage + 2)
for (let i = start; i <= end; i++) range.push(i)
return range
})
</script>
<template>
<nav v-if="totalPages > 1" class="pagination">
<button
:disabled="currentPage <= 1"
@click="emit('change', currentPage - 1)"
>
Anterior
</button>
<button
v-for="p in pages"
:key="p"
:class="{ active: p === currentPage }"
@click="emit('change', p)"
>
{{ p }}
</button>
<button
:disabled="currentPage >= totalPages"
@click="emit('change', currentPage + 1)"
>
Proximo
</button>
</nav>
</template>Passo 6 — A View (Compondo Tudo)
<!-- src/modules/products/views/ProductsView.vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useProductsStore } from '../stores/products-store'
import { useProductsList } from '../composables/useProductsList'
import ProductSearch from '../components/ProductSearch.vue'
import CategoryFilter from '../components/CategoryFilter.vue'
import ProductsTable from '../components/ProductsTable.vue'
import AppPagination from '@/shared/components/AppPagination.vue'
const store = useProductsStore()
const { searchQuery, selectedCategory, currentPage, hasActiveFilters } = storeToRefs(store)
const { items, totalPages, totalCount, isLoading, isFetching, isEmpty } = useProductsList({
page: currentPage,
search: searchQuery,
category: selectedCategory,
})
const categories = [
{ value: 'electronics', label: 'Eletronicos' },
{ value: 'clothing', label: 'Roupas' },
{ value: 'books', label: 'Livros' },
]
</script>
<template>
<div class="products-view">
<header>
<h1>Produtos ({{ totalCount }})</h1>
<button v-if="hasActiveFilters" @click="store.clearFilters()">
Limpar filtros
</button>
</header>
<div class="filters">
<ProductSearch v-model="searchQuery" @update:model-value="store.setSearch" />
<CategoryFilter
:selected="selectedCategory"
:categories="categories"
@change="store.setCategory"
/>
</div>
<!-- Indicador de carregamento para refetch em segundo plano -->
<div v-if="isFetching && !isLoading" class="refetching">
Atualizando...
</div>
<ProductsTable :products="items" :loading="isLoading" />
<div v-if="isEmpty && hasActiveFilters" class="no-results">
Nenhum produto corresponde aos seus filtros.
<button @click="store.clearFilters()">Limpar filtros</button>
</div>
<AppPagination
:current-page="currentPage"
:total-pages="totalPages"
@change="store.setPage"
/>
</div>
</template>Como as Pecas se Conectam
Pontos-Chave
- Pinia Store armazena o estado dos filtros (busca, categoria, pagina) — persiste entre navegacoes
- Vue Query reage a mudancas na queryKey — sem necessidade de refetch manual
keepPreviousDatamantem a pagina anterior visivel enquanto carrega a proxima- Voltar para a pagina 1 quando os filtros mudam — evita exibir uma pagina 5 vazia
- Debounce na busca — nao faca chamadas a API a cada tecla digitada
Proximos Passos
- Tutorial de Modulo CRUD — Modulo completo com formularios e mutations
- Guia de Migracao — Migre seu projeto para esta arquitetura