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.



