Destino Dev

Blog

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

  1. Adicione debounce (200–500ms) para reduzir chamadas.
  2. Use AbortController ou token para evitar race conditions.
  3. Implemente cache compartilhado (Pinia) com TTL e, se útil, persistência básica (localStorage).
  4. 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