Autocomplete reativo em Vue 3 com debounce, cache local e Pinia
Por John Pires · 12 de março de 2026

Autocomplete reativo em Vue 3 com debounce, cache local e Pinia
Contexto: quando você vai usar isso
Por que autocomplete importa
Autocomplete acelera a interação do usuário, reduz digitação e melhora conversão em formulários de busca. Em aplicações modernas, uma implementação ruim pode gerar excesso de requisições, lentidão e custo extra em APIs. Por isso, aprender debounce e cache local é essencial.
Quando aplicar debounce e cache
Use debounce sempre que a entrada do usuário acionar chamadas remotas baseadas em texto (ex: buscar nome, produto, cidade). Use cache local quando a mesma consulta for repetida frequentemente durante a sessão ou quando o conjunto de resultados muda pouco no tempo.
Resumo técnico do que vamos cobrir
Você verá: uma versão errada (fazer requisições imediatas), uma versão correta com debounce customizado, e uma versão profissional usando Pinia para cache e persistência. Tudo com Composition API e exemplos funcionais.
Explicação visual com diagramas mentais
Fluxo de dados — alto nível
Imagine o fluxo como uma linha: input do usuário → processamento local (debounce) → verificação de cache → requisição remota (se necessário) → resposta → renderização. Cada etapa é uma oportunidade de evitar trabalho desnecessário.
Diagrama mental (ASCII)
User types -> [Debounce timer] --+--> [Cache lookup] --> hit -> show results
|
+--> miss -> [API call] -> save cache -> show results
Esse diagrama mostra as duas decisões cruciais: eliminar chamadas rápidas demais e evitar chamadas quando o cache tem a resposta.
Dicas visuais rápidas
Debounce = agrupar eventos; Cache = lembrar resultados; Pinia = fonte de verdade compartilhada entre componentes.
Entender essas responsabilidades evita acoplamento e confusão entre estado do componente e estado global.
Primeiro exemplo simples
Versão errada (fiz isso em code review)
Iniciante comum: ligar o @input diretamente a uma função que faz fetch a cada caractere. Isso gera dezenas de requisições por segundo e a UI fica inconsistente quando respostas chegam fora de ordem.
Exemplo errado: componente que chama a API a cada input
O código abaixo mostra um componente Vue 3 com Composition API que faz fetch sem debounce. Contexto: componente Autocomplete que chama /api/search?q= a cada alteração do campo.
<template>
<input v-model="query" placeholder="buscar" />
<ul>
<li v-for="item in results" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script setup>
import { ref, watch } from 'vue'
const query = ref('')
const results = ref([])
watch(query, async (q) => {
if (!q) { results.value = []; return }
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
results.value = await res.json()
})
</script>
O que acontece: toda tecla dispara uma requisição. Problemas: saturação da API, respostas fora de ordem, experiência ruim em redes lentas.
Por que isso está errado — análise
O watch aqui não limita a frequência de chamadas. Além disso, não há proteção contra respostas que chegam fora de ordem (race conditions). Não há cache: repetir a mesma consulta fará nova chamada.
Segundo exemplo mais realista
Melhorando com debounce customizado
Debounce agrupa eventos e executa a ação apenas após um período de inatividade. A seguir uma utilidade debounce simples e robusta, sem dependências externas. Usei setTimeout/clearTimeout para ensinar o mecanismo.
// debounce.js
// O arquivo exporta uma função debounce que retorna uma função debounced
export function debounce(fn, wait = 300) {
let timer = null
return (...args) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = null
fn(...args)
}, wait)
}
}
Contexto: essa função recebe callback e tempo. Ela garante que o callback seja chamado apenas após o usuário parar de digitar por wait ms.
Uso do debounce no componente (correto)
Exemplo: o componente usa a função debounce para evitar chamadas excessivas. Também cuidamos de abort controller para cancelar fetches pendentes e de ordenar respostas usando token de requisição.
<template>
<input v-model="query" placeholder="buscar" />
<ul>
<li v-for="item in results" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
import { debounce } from './debounce'
const query = ref('')
const results = ref([])
let lastToken = 0
let controller = null
const doSearch = async (q, token) => {
if (controller) controller.abort()
controller = new AbortController()
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
const data = await res.json()
// rejeita respostas antigas
if (token !== lastToken) return
results.value = data
} catch (err) {
if (err.name === 'AbortError') return
console.error(err)
}
}
const debouncedSearch = debounce((q) => {
lastToken = Date.now()
const token = lastToken
doSearch(q, token)
}, 300)
// efeito simples: observar query e chamar debouncedSearch
watch(query, (q) => {
if (!q) { results.value = []; return }
debouncedSearch(q)
})
</script>
O que mudou: chamadas agrupadas, requisições abortáveis e proteção contra respostas fora de ordem. Isso já resolve muitos problemas de performance e UX.
Cache local com Pinia — por que usar store
Pinia facilita compartilhar cache entre componentes e persistir em localStorage. Ao centralizar cache, você evita duplicar lógica e garante que consultas iguais retornem rapidamente sem tocar a API.
Próximo passo: adicionar uma store de cache com TTL (tempo de vida) e salvar em localStorage.
Implementando cache com Pinia (exemplo completo)
Pinia store: cache com TTL e persistência
Crie uma store que guarda um Map de query → { results, timestamp }. A store verifica TTL antes de retornar um valor. Também sincroniza com localStorage para persistência simples.
// stores/searchCache.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSearchCache = defineStore('searchCache', () => {
const store = ref(new Map())
const TTL = 1000 * 60 * 5 // 5 minutos
const load = () => {
try {
const raw = localStorage.getItem('search-cache')
if (!raw) return
const parsed = JSON.parse(raw)
const map = new Map(parsed)
store.value = map
} catch (err) {
console.warn('could not load cache', err)
}
}
const save = () => {
try {
const arr = Array.from(store.value.entries())
localStorage.setItem('search-cache', JSON.stringify(arr))
} catch (err) {
console.warn('could not save cache', err)
}
}
const get = (q) => {
const entry = store.value.get(q)
if (!entry) return null
if (Date.now() - entry.timestamp > TTL) {
store.value.delete(q)
save()
return null
}
return entry.results
}
const set = (q, results) => {
store.value.set(q, { results, timestamp: Date.now() })
save()
}
load()
return { get, set }
})
Contexto: essa store fornece get/set e cuida de persistência. TTL evita que dados fiquem eternamente obsoletos.
Componente que usa Pinia para cache
Agora o componente consulta primeiro a store. Se houver hit, retorna imediatamente. Se miss, faz a requisição, salva no cache e atualiza a UI. Mantemos debounce e AbortController.
<template>
<input v-model="query" placeholder="buscar" />
<ul>
<li v-for="item in results" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script setup>
import { ref, watch } from 'vue'
import { debounce } from './debounce'
import { useSearchCache } from './stores/searchCache'
const cache = useSearchCache()
const query = ref('')
const results = ref([])
let controller = null
let lastToken = 0
const doSearch = async (q, token) => {
// primeiro, verifica cache
const cached = cache.get(q)
if (cached) {
results.value = cached
return
}
if (controller) controller.abort()
controller = new AbortController()
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
const data = await res.json()
if (token !== lastToken) return
results.value = data
cache.set(q, data)
} catch (err) {
if (err.name === 'AbortError') return
console.error(err)
}
}
const debouncedSearch = debounce((q) => {
lastToken = Date.now()
const token = lastToken
doSearch(q, token)
}, 300)
watch(query, (q) => {
if (!q) { results.value = []; return }
debouncedSearch(q)
})
</script>
O que acontece: buscas repetidas são instantâneas (cache), chamadas remotas são minimizadas (debounce) e a store mantém consistência entre componentes.
Erros comuns de iniciante e como evitar
Erro 1 — não debouncing e taxas de requisição altas
Sintoma: números enormes de requisições por digitação. Solução: debounce simples ou throttle se necessário. Use 200–500ms como ponto de partida.
Erro 2 — race conditions e respostas fora de ordem
Sintoma: usuário digita 'a', depois 'ab', a resposta de 'a' chega depois e sobrepõe. Solução: token de requisição (timestamp) ou AbortController para cancelar requests anteriores.
Erro 3 — cache compartilhado errado
Sintoma: cache sendo armazenado em estado local do componente leva a duplicação e inconsistência. Solução: centralize o cache em Pinia ou outro store. Salve apenas o que é serializável para persistência.
Erro 4 — armazenar dados sensíveis no localStorage
Não coloque dados sensíveis em localStorage. Use cache apenas para coisas públicas e não críticas.
Erros de UX que também são performance
- Falta de feedback visual (loading) — o usuário re-digita sem saber se algo acontece;
- Ordenação indefinida dos resultados — use scoring no servidor ou cliente;
- Sem debounce em teclados móveis — piores impactos em redes móveis.
Dica: combine debounce curto (200–300ms) com cache in-memory para latências quase instantâneas sem sacrificar frescor dos dados.
Conclusão prática
Checklist para implementar um autocomplete reativo
- Adicione debounce (200–500ms) para reduzir chamadas.
- Use AbortController ou token para evitar race conditions.
- Implemente cache compartilhado (Pinia) com TTL e, se útil, persistência básica (localStorage).
- Forneça feedback visual e trate erros de rede.
Resumo final e próximos passos
Você viu o jeito errado (chamar API a cada tecla) e duas correções: debounce + abort e Pinia para cache. Comece com a versão com debounce. Quando houver múltiplos componentes ou necessidade de persistência, mova o cache para Pinia. Teste com redes lentas e verfique a ordem de chegada das respostas.
Implementações futuras podem incluir: searching por paginação, memorandos por similaridade (fuzzy cache), invalidação baseada em eventos de servidor (websocket) ou uso de IndexedDB para caches maiores.
Se preferir usar bibliotecas prontas, considere lodash.debounce para debounce e vue-query (ou TanStack Query) para gerenciamento de cache e sincronização, mas entenda primeiro a lógica manual mostrada aqui.
Implementar debounce e cache corretamente melhora performance, reduz custo de backend e eleva a qualidade do produto. Faça code review focado em: onde estão as chamadas da API, como você lida com concorrência, e onde o estado é mantido.
Pronto para subir isso no seu projeto? Comece com o debounce utilitário, adicione store de cache simples e monitore as métricas de requisição. Ajuste TTL e debounce conforme comportamento real dos usuários.
Fim. Código de exemplo está pronto para copiar e testar em um projeto Vue 3 com Pinia já configurado.
Assuntos: Vue.js, pinia, autocomplete, debounce, reactivity, JavaScript
Leia também
Cache reativo com Pinia + IndexedDB: estratégia offline-first no Vue 3
Padrão offline-first prático: integrar Pinia e IndexedDB para um cache reativo no Vue 3. Cache imediato na UI, persistência durável, atualizações em…
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…
Hydration parcial no Vue 3: otimizar SSR com Pinia, Suspense e lazy components
Padrão prático de partial hydration em Vue 3: como transferir estado do Pinia, montar islands com Suspense e lazy components, e reduzir TTI sem hidratar a…
Scroll infinito reativo no Vue 3 com Composition API e Pinia
Aprenda a implementar scroll infinito reativo em Vue 3 usando Composition API e Pinia: do setup ao mini-projeto, com exemplos práticos, store…
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…
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…