Destino Dev

Blog

Cache reativo com Pinia + IndexedDB: estratégia offline-first no Vue 3

Por John Pires · 12 de março de 2026

Cenário prático do dia a dia

Times de produto que entregam aplicações PWA ou web apps progressivas enfrentam dois problemas recorrentes: inconsistência de dados quando a rede oscila e UX degradada por carregamentos desnecessários. Um padrão eficiente é manter um cache local reativo que sirva a UI imediatamente e sincronize em segundo plano com a API. Aqui vamos construir uma estratégia offline-first combinando Pinia para reatividade e IndexedDB para persistência durável.

O objetivo não é substituir um backend ou resolver sincronização complexa de conflitos, mas oferecer uma camada de cache simples, previsível e reativa que reduz latência na UI, permite uso offline e simplifica retrys automáticos. A abordagem que descrevo é aplicável a listas de recursos, detalhes de entidades e formulários simples.

Este cenário assume que você já usa Vue 3 com Composition API e Pinia. Vamos abordar um fluxo típico: carregar lista de itens, exibir cache imediatamente, atualizar em background, persistir mudanças locais e lidar com erros de IndexedDB ou limites de quota.

Antes de codificar, enumere requisitos técnicos práticos: 1) leitura instantânea da UI via cache reativo; 2) persistência entre sessões; 3) atualização em background sem bloquear render; 4) gerenciamento de versões do schema do cache; 5) tratamento de falhas de escrita/recuperação.

Por que Pinia + IndexedDB

Pinia fornece a reatividade que a UI consome diretamente. IndexedDB resolve persistência para grandes payloads e objetos complexos (diferente de localStorage que é síncrono e limitado). Integrá-los traz o melhor dos dois mundos: reatividade instantânea e durabilidade offline.

Com Pinia você mantém a lógica de leitura/gravação num único lugar: store. Observadores reativos podem escutar mudanças e acionar persistência. Separando o adaptador de IndexedDB como uma dependência injetável, você mantém testabilidade e modularidade.

Casos reais que vou cobrir

Exemplos práticos: lista de tarefas com cache, pré-preenchimento de formulário com dados offline, atualização de item em background, e rollback em caso de conflito ou falha de gravação. Também veremos migração de schema e limpeza de cache por versão.

Dica: pense o cache como uma cópia local que precisa de políticas claras de invalidação. Evite heurísticas implícitas; defina TTL, versão e gatilhos de sincronização.

Arquitetura da solução

A arquitetura proposta tem três camadas: API layer (fetch/axios), store reativo (Pinia) e persistence layer (IndexedDB). O fluxo básico: componente solicita dados ao store -> store retorna cache imediatamente -> store dispara fetch em background -> on success atualiza cache e IndexedDB -> on failure mantém cache e marca erro.

Key concepts:

  • Cache as source of truth: UI consome sempre o estado do Pinia.
  • Eventual consistency: network updates chegam e reconciliam estado local.
  • Persistence adapter: encapsula todas as operações IndexedDB, incluindo versionamento e compactação.

Componentes da arquitetura

1) IndexedDB adapter - abstrai openDB, get, set, delete, clear e migracoes. 2) Pinia store - state reativo com actions que leem/persistem e expõem indicadores de loading/err. 3) Hooks de consumo - useCachedFetch que encapsula o fluxo de leitura do store e refresh em background.

Essa separação permite trocar IndexedDB por outro storage (por exemplo, um service worker cache) sem tocar componentes nem a maioria das ações da store.

Fluxos e políticas

Defina políticas básicas:

  1. Cache-first para leitura imediata.
  2. Background-refresh para garantir frescor eventual.
  3. TTL opcional por coleção, para invalidar after X minutos.
  4. Conflict handling: last-write-wins por padrão, com opção de merge em casos específicos.

Código completo com explicações

A seguir implemento três artefatos: adapter idb (utilizando idb), Pinia store com persistência reativa e um composable que componentes consomem. Cada bloco tem contexto e explicação do que acontece antes e depois do código.

IndexedDB adapter (idb)

O adapter encapsula openDB e operações básicas. Uso a biblioteca idb (https://github.com/jakearchibald/idb) para simplificar a API. O adapter tem versão e migração simples.

/* arquivo: src/lib/idb-adapter.ts
   O que faz: cria/abre o DB, oferece get/set/remove/listAll e applyMigration.
   Como usar: importar e chamar initDB(); depois usar get/set com a store. */
import { openDB } from 'idb'

const DB_NAME = 'app-cache-db'
const DB_VERSION = 1
const STORE_NAME = 'cache'

export async function initDB() {
  const db = await openDB(DB_NAME, DB_VERSION, {
    upgrade(upgradeDb, oldVersion, newVersion, transaction) {
      if (!upgradeDb.objectStoreNames.contains(STORE_NAME)) {
        upgradeDb.createObjectStore(STORE_NAME)
      }
      // aqui você pode aplicar migrações entre versões
    }
  })
  return db
}

export async function idbGet(db, key) {
  return await db.get(STORE_NAME, key)
}

export async function idbSet(db, key, value) {
  return await db.put(STORE_NAME, value, key)
}

export async function idbDelete(db, key) {
  return await db.delete(STORE_NAME, key)
}

export async function idbKeys(db) {
  return await db.getAllKeys(STORE_NAME)
}

Depois da chamada initDB(), o adapter expõe promessas para operação. Em um ambiente de produção, trate quotas e erros (ver seção de erros abaixo).

Pinia store com persistência reativa

O store mantém o cache em memória e sincroniza com IndexedDB. Uso watch para persistir mudanças de forma debounced, evitando chamadas excessivas ao disco.

/* arquivo: src/stores/useCacheStore.ts
   O que faz: define um store Pinia que carrega do IndexedDB no init
   e persiste alterações. Também expõe fetchWithCache para carregar do backend. */
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { initDB, idbGet, idbSet } from '@/lib/idb-adapter'

export const useCacheStore = defineStore('cache', () => {
  const dbPromise = initDB()
  const cache = ref({})
  const loading = ref(false)
  const errors = ref({})

  // carrega do IDB na inicialização
  ;(async () => {
    try {
      const db = await dbPromise
      const stored = await idbGet(db, 'app_cache')
      if (stored) cache.value = stored
    } catch (e) {
      console.error('idb load error', e)
    }
  })()

  async function persist() {
    try {
      const db = await dbPromise
      await idbSet(db, 'app_cache', cache.value)
    } catch (e) {
      console.error('idb save error', e)
      throw e
    }
  }

  // persiste mudanças com debounce simples
  let persistTimeout
  watch(cache, () => {
    clearTimeout(persistTimeout)
    persistTimeout = setTimeout(() => {
      persist().catch(() => {
        // já logado, opcional: marcar indicador de erro
      })
    }, 300)
  }, { deep: true })

  async function fetchWithCache(key, fetcher) {
    loading.value = true
    errors.value[key] = null

    // serve cache imediato
    const cached = cache.value[key]

    // dispara fetch em background
    try {
      const fresh = await fetcher()
      cache.value = { ...cache.value, [key]: fresh }
      loading.value = false
      return fresh
    } catch (e) {
      loading.value = false
      errors.value[key] = e
      // retorna cache mesmo que stale
      if (cached !== undefined) return cached
      throw e
    }
  }

  return { cache, loading, errors, fetchWithCache }
})

Após a store carregar, componentes podem consumir cache.value e usar fetchWithCache para atualizar. O watch garante persistir no IDB quando a store muda.

Composable de consumo no componente

Este composable é usado em componentes Vue para encapsular o padrão: buscar recursos com cache e refrescar na montagem.

/* arquivo: src/composables/useCachedApi.ts
   O que faz: wrapper para usar o cache store e fornecer estado para componentes.
   Uso: const { data, loading, refresh } = useCachedApi('todos', () => fetchTodos()) */
import { useCacheStore } from '@/stores/useCacheStore'
import { computed, onMounted } from 'vue'

export function useCachedApi(key, fetcher) {
  const store = useCacheStore()
  const data = computed(() => store.cache[key])
  const loading = computed(() => store.loading)
  const error = computed(() => store.errors[key])

  async function refresh() {
    return store.fetchWithCache(key, fetcher)
  }

  onMounted(() => {
    // se não houver cache, tenta buscar
    if (!store.cache[key]) {
      refresh().catch(() => {})
    } else {
      // sempre tenta atualizar em background
      refresh().catch(() => {})
    }
  })

  return { data, loading, error, refresh }
}

Com esse composable, um componente consome dados reativos, recebe atualizações automaticamente e não precisa conhecer IndexedDB nem regras de persistência.

Tratamento de erros e edge cases

IndexedDB e ambiente offline trazem várias armadilhas: falhas por quota, objetos não serializáveis, migração de schema e concorrência entre abas. Aqui estão estratégias práticas para cada caso.

Quota, writes e fallback

Browsers podem recusar writes quando o storage atinge quota. Estratégia: detectar erro na escrita e tentar fallback para localStorage apenas com metadados essenciais. Não armazene blobs grandes no fallback.

Exemplo de tratamento: ao falhar idbSet, remover entradas antigas menos usadas e tentar novamente. Use LRU simples com timestamps de lastAccess.

Migração de schema e versão

Inclua versionamento no DB e no objeto salvo. Ao detectar versão diferente, aplique migração controlada ou invalide o cache. Migrations devem ser idempotentes e registradas no adapter.

Se a migração falhar, registre erro e opte por limpar cache e alertar o usuário de forma não intrusiva.

Prática: sempre inclua um campo _meta: { version: X, ts: Date.now() } no objeto salvo para facilitar diagnóstico e migrações.

Performance e otimização

Persistência frequente pode degradar performance. Estratégias: debouncing de gravações, gravações incrementais por key em vez de gravar todo o objeto cache, compressão de payloads e limpeza periódica de dados antigos.

Gravação incremental

Em vez de idbSet(db, 'app_cache', cache) gravar o objeto inteiro, mantenha itens por key e salve somente a key que mudou. Isso reduz I/O e evita escritas concorrentes longas.

Refatore o adapter para usar object store keys individuais e no store chamar idbSet(db, key, value) para cada entry atualizada.

Debounce e batch

Use debounce e batch para agrupar mudanças frequentes (ex: digitação no UI). O exemplo anterior já aplica debounce de 300ms; ajuste conforme perfil do app. Para listas grandes, considere batch de 1s e persistência incremental.

Erros comuns, armadilhas e dicas práticas

Erros comuns

1) Serialização: funções e instâncias de classe não são serializáveis em IndexedDB. Salve objetos puros ou serialize manualmente. 2) Concurrency: várias abas escrevendo o mesmo key podem sobrescrever. Use last-write-wins ou canais de broadcast para sync entre abas.

3) Esquecer de lidar com promises: operações IDB são assíncronas; não trate writes como sincronos. Isso evita condições de corrida e perda de dados.

Dicas práticas

  • Inclua um indicador de 'stale' por item baseado em TTL para dar contexto ao usuário.
  • Evite gravar dados sensíveis sem criptografia.
  • Teste limites: encha o storage em ambiente de teste para ver comportamento de fallback.

Conclusão prática

O padrão cache-first com Pinia + IndexedDB oferece UX responsiva e suporte offline sem grande complexidade. A arquitetura proposta mantém a UI consumindo um único estado reativo, enquanto um adapter de IndexedDB garante durabilidade e versionamento. Coube aqui padrões de persistência incremental, debouncing e tratamento de erros práticos como fallback para quota e migrações.

Implementação recomendada: começar simples (cache por key, persistência debounced), medir e otimizar gravações, e adicionar migrações e LRU conforme necessidade. Use o composable para isolar complexidade e manter componentes limpos.

Resumo prático: 1) mantenha cache no Pinia; 2) persista no IndexedDB via adapter; 3) sirva cache imediato e atualize em background; 4) trate erros de escrita e migração; 5) otimize gravações com debounce e persistência incremental.

Aplicando essas práticas, você terá uma base sólida para features offline-first no Vue 3 sem perder a previsibilidade e controle sobre dados persistidos.

Assuntos: Vue.js, pinia, indexeddb, offline-first, Performance

Leia também