How to use Vue with IndexedDB

IndexedDB provides client-side database storage for large amounts of structured data, enabling offline-first applications and improved performance through local data caching. As the creator of CoreUI, a widely used open-source UI library, I’ve implemented IndexedDB solutions in production Vue applications throughout my 12 years of frontend development since 2014. The most practical approach is creating a composable that wraps IndexedDB operations with reactive Vue refs and proper error handling. This method provides type-safe database access, reactive data synchronization, and seamless integration with Vue’s composition API and component lifecycle.

Create IndexedDB wrapper composable with reactive refs, implement CRUD operations, handle database versioning and error states.

// src/composables/useIndexedDB.js
import { ref, onMounted } from 'vue'

export function useIndexedDB(dbName, storeName, version = 1) {
  const db = ref(null)
  const error = ref(null)
  const loading = ref(false)

  const initDB = () => {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(dbName, version)

      request.onerror = () => {
        error.value = 'Failed to open database'
        reject(request.error)
      }

      request.onsuccess = () => {
        db.value = request.result
        resolve(db.value)
      }

      request.onupgradeneeded = (event) => {
        const database = event.target.result

        if (!database.objectStoreNames.contains(storeName)) {
          const objectStore = database.createObjectStore(storeName, {
            keyPath: 'id',
            autoIncrement: true
          })

          objectStore.createIndex('name', 'name', { unique: false })
          objectStore.createIndex('createdAt', 'createdAt', { unique: false })
        }
      }
    })
  }

  const add = async (data) => {
    loading.value = true
    error.value = null

    try {
      if (!db.value) await initDB()

      return new Promise((resolve, reject) => {
        const transaction = db.value.transaction([storeName], 'readwrite')
        const objectStore = transaction.objectStore(storeName)

        const request = objectStore.add({
          ...data,
          createdAt: new Date().toISOString()
        })

        request.onsuccess = () => {
          resolve(request.result)
        }

        request.onerror = () => {
          error.value = 'Failed to add data'
          reject(request.error)
        }

        transaction.oncomplete = () => {
          loading.value = false
        }
      })
    } catch (err) {
      error.value = err.message
      loading.value = false
      throw err
    }
  }

  const get = async (id) => {
    loading.value = true
    error.value = null

    try {
      if (!db.value) await initDB()

      return new Promise((resolve, reject) => {
        const transaction = db.value.transaction([storeName], 'readonly')
        const objectStore = transaction.objectStore(storeName)
        const request = objectStore.get(id)

        request.onsuccess = () => {
          loading.value = false
          resolve(request.result)
        }

        request.onerror = () => {
          error.value = 'Failed to get data'
          loading.value = false
          reject(request.error)
        }
      })
    } catch (err) {
      error.value = err.message
      loading.value = false
      throw err
    }
  }

  const getAll = async () => {
    loading.value = true
    error.value = null

    try {
      if (!db.value) await initDB()

      return new Promise((resolve, reject) => {
        const transaction = db.value.transaction([storeName], 'readonly')
        const objectStore = transaction.objectStore(storeName)
        const request = objectStore.getAll()

        request.onsuccess = () => {
          loading.value = false
          resolve(request.result)
        }

        request.onerror = () => {
          error.value = 'Failed to get all data'
          loading.value = false
          reject(request.error)
        }
      })
    } catch (err) {
      error.value = err.message
      loading.value = false
      throw err
    }
  }

  const update = async (id, data) => {
    loading.value = true
    error.value = null

    try {
      if (!db.value) await initDB()

      return new Promise((resolve, reject) => {
        const transaction = db.value.transaction([storeName], 'readwrite')
        const objectStore = transaction.objectStore(storeName)

        const getRequest = objectStore.get(id)

        getRequest.onsuccess = () => {
          const existingData = getRequest.result

          if (!existingData) {
            error.value = 'Record not found'
            loading.value = false
            reject(new Error('Record not found'))
            return
          }

          const updatedData = {
            ...existingData,
            ...data,
            updatedAt: new Date().toISOString()
          }

          const updateRequest = objectStore.put(updatedData)

          updateRequest.onsuccess = () => {
            loading.value = false
            resolve(updateRequest.result)
          }

          updateRequest.onerror = () => {
            error.value = 'Failed to update data'
            loading.value = false
            reject(updateRequest.error)
          }
        }

        getRequest.onerror = () => {
          error.value = 'Failed to find record'
          loading.value = false
          reject(getRequest.error)
        }
      })
    } catch (err) {
      error.value = err.message
      loading.value = false
      throw err
    }
  }

  const remove = async (id) => {
    loading.value = true
    error.value = null

    try {
      if (!db.value) await initDB()

      return new Promise((resolve, reject) => {
        const transaction = db.value.transaction([storeName], 'readwrite')
        const objectStore = transaction.objectStore(storeName)
        const request = objectStore.delete(id)

        request.onsuccess = () => {
          loading.value = false
          resolve(true)
        }

        request.onerror = () => {
          error.value = 'Failed to delete data'
          loading.value = false
          reject(request.error)
        }
      })
    } catch (err) {
      error.value = err.message
      loading.value = false
      throw err
    }
  }

  const clear = async () => {
    loading.value = true
    error.value = null

    try {
      if (!db.value) await initDB()

      return new Promise((resolve, reject) => {
        const transaction = db.value.transaction([storeName], 'readwrite')
        const objectStore = transaction.objectStore(storeName)
        const request = objectStore.clear()

        request.onsuccess = () => {
          loading.value = false
          resolve(true)
        }

        request.onerror = () => {
          error.value = 'Failed to clear data'
          loading.value = false
          reject(request.error)
        }
      })
    } catch (err) {
      error.value = err.message
      loading.value = false
      throw err
    }
  }

  onMounted(() => {
    initDB()
  })

  return {
    db,
    error,
    loading,
    initDB,
    add,
    get,
    getAll,
    update,
    remove,
    clear
  }
}
<!-- src/components/TodoList.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import { useIndexedDB } from '../composables/useIndexedDB'

const { add, getAll, update, remove, loading, error } = useIndexedDB('TodoApp', 'todos', 1)

const todos = ref([])
const newTodo = ref('')

const loadTodos = async () => {
  try {
    todos.value = await getAll()
  } catch (err) {
    console.error('Failed to load todos:', err)
  }
}

const addTodo = async () => {
  if (!newTodo.value.trim()) return

  try {
    await add({
      name: newTodo.value,
      completed: false
    })
    newTodo.value = ''
    await loadTodos()
  } catch (err) {
    console.error('Failed to add todo:', err)
  }
}

const toggleTodo = async (todo) => {
  try {
    await update(todo.id, {
      completed: !todo.completed
    })
    await loadTodos()
  } catch (err) {
    console.error('Failed to update todo:', err)
  }
}

const deleteTodo = async (id) => {
  try {
    await remove(id)
    await loadTodos()
  } catch (err) {
    console.error('Failed to delete todo:', err)
  }
}

onMounted(() => {
  loadTodos()
})
</script>

<template>
  <div class='todo-app'>
    <h2>IndexedDB Todo List</h2>

    <div v-if='error' class='error'>
      {{ error }}
    </div>

    <div class='add-todo'>
      <input
        v-model='newTodo'
        @keyup.enter='addTodo'
        placeholder='Add new todo...'
        :disabled='loading'
      />
      <button @click='addTodo' :disabled='loading || !newTodo.trim()'>
        Add
      </button>
    </div>

    <div v-if='loading' class='loading'>
      Loading...
    </div>

    <ul v-else class='todo-list'>
      <li v-for='todo in todos' :key='todo.id' class='todo-item'>
        <input
          type='checkbox'
          :checked='todo.completed'
          @change='toggleTodo(todo)'
        />
        <span :class='{ completed: todo.completed }'>
          {{ todo.name }}
        </span>
        <button @click='deleteTodo(todo.id)' class='delete-btn'>
          Delete
        </button>
      </li>
    </ul>

    <p v-if='todos.length === 0 && !loading' class='empty'>
      No todos yet. Add one above!
    </p>
  </div>
</template>

<style scoped>
.completed {
  text-decoration: line-through;
  opacity: 0.6;
}

.error {
  color: red;
  padding: 0.5rem;
  margin-bottom: 1rem;
}

.loading {
  text-align: center;
  padding: 1rem;
}
</style>

Here the useIndexedDB composable encapsulates IndexedDB operations with reactive state management. The indexedDB.open creates or opens database with specified version number. The onupgradeneeded event handler runs when database version changes, creating object stores and indexes. The createObjectStore defines primary key using keyPath and enables autoIncrement for automatic ID generation. The createIndex adds indexes for efficient querying by specific fields. The transaction method creates database transactions with readwrite or readonly modes. The objectStore method accesses specific store within transaction. The Promise wrappers convert callback-based IndexedDB API to async/await pattern. The reactive refs (loading, error) provide UI feedback during database operations.

Best Practice Note:

This is the IndexedDB implementation pattern we use in CoreUI Vue templates for offline-capable applications requiring local data persistence. Handle database version migrations carefully by checking existing object stores before creating new ones, implement data synchronization strategies for syncing IndexedDB with backend APIs when online, and consider using libraries like Dexie.js for more convenient IndexedDB access with better query capabilities and TypeScript support.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author