useIndexedDB
A hook for storing large amounts of structured data in IndexedDB
A React hook that provides CRUD operations for IndexedDB, the browser's built-in database for storing large amounts of structured data. Unlike localStorage which is limited to ~5MB of string data, IndexedDB can store gigabytes of any data type including objects, arrays, files, and blobs.
Source Code
View the full hook implementation in the Hook Source Code section below.
What is IndexedDB?
IndexedDB is a low-level API for client-side storage of structured data. It's perfect for:
- Offline-first applications - Cache API responses for offline access
- Large datasets - Store thousands of records without hitting size limits
- Complex data - Store objects with nested structures, arrays, and relationships
- Binary data - Store files, images, and blobs directly
Verify in DevTools!
Open Chrome DevTools → Application tab → IndexedDB (under Storage) to see your data. You can view, edit, and delete records directly!
Features
- Full CRUD -
add,update,remove,getByKey,clearoperations - SSR Safe - Checks for IndexedDB support before initialization
- Type Safe - Full TypeScript support with generics
- Auto Refresh - Automatically refreshes data after mutations
- Error Handling - Comprehensive error state management
- Large Data - Handle megabytes of data (not limited like localStorage)
Learn More
MDN: IndexedDB API
Complete IndexedDB documentation and tutorials
web.dev: IndexedDB
Best practices for using IndexedDB in web apps
Basic Usage - Notes App
A simple notes application that persists notes to IndexedDB:
Try it out!
Add some notes, then close the browser completely and reopen - your notes persist! Check DevTools → Application → IndexedDB → notesApp to see the stored data.
"use client";
import { useState } from "react";
import { useIndexedDB } from "@repo/hooks/storage/use-indexed-db";
interface Note {
id: string;
title: string;
content: string;
createdAt: number;
}
/* BASIC USAGE - Notes App */
export const Example1 = () => {
const {
data: notes,
add,
remove,
isLoading,
isSupported,
error,
} = useIndexedDB<Note>({
dbName: "notesApp",
storeName: "notes",
keyPath: "id",
});
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
if (!isSupported) {
return (
<div className="rounded-md border border-yellow-500/50 bg-yellow-500/10 p-4">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
IndexedDB is not supported in this browser
</p>
</div>
);
}
if (isLoading) {
return (
<span className="text-muted-foreground text-sm">Loading...</span>
);
}
const handleAdd = async () => {
if (!title.trim()) return;
await add({
id: crypto.randomUUID(),
title,
content,
createdAt: Date.now(),
});
setTitle("");
setContent("");
};
return (
<div className="flex w-full max-w-md flex-col gap-4">
{error && <p className="text-destructive text-sm">{error}</p>}
<div className="flex flex-col gap-2">
<input
className="bg-background rounded-md border px-3 py-2 text-sm"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Note title..."
/>
<textarea
className="bg-background rounded-md border px-3 py-2 text-sm"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Note content..."
rows={2}
/>
<button
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm"
onClick={handleAdd}
>
Add Note
</button>
</div>
<div className="flex flex-col gap-2">
{notes.length === 0 ? (
<p className="text-muted-foreground text-sm">
No notes yet
</p>
) : (
notes.map((note) => (
<div
key={note.id}
className="flex items-start justify-between rounded-md border p-3"
>
<div>
<h4 className="text-sm font-medium">
{note.title}
</h4>
<p className="text-muted-foreground text-xs">
{note.content}
</p>
</div>
<button
className="text-destructive hover:text-destructive/80"
onClick={() => remove(note.id)}
>
×
</button>
</div>
))
)}
</div>
</div>
);
};
Shopping Cart
A persistent shopping cart that survives browser restarts. Demonstrates add, update, remove, and clear operations:
Try it out!
Add products to cart, adjust quantities, then reload the page - your cart is preserved! View the data in DevTools → Application → IndexedDB → shopApp → cart.
"use client";
import { useIndexedDB } from "@repo/hooks/storage/use-indexed-db";
interface Product {
id: string;
name: string;
price: number;
quantity: number;
}
/* SHOPPING CART - Persistent Cart */
export const Example2 = () => {
const {
data: cart,
add,
update,
remove,
clear,
isLoading,
isSupported,
} = useIndexedDB<Product>({
dbName: "shopApp",
storeName: "cart",
keyPath: "id",
});
if (!isSupported) {
return (
<div className="rounded-md border border-yellow-500/50 bg-yellow-500/10 p-4">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
IndexedDB is not supported
</p>
</div>
);
}
if (isLoading) {
return (
<span className="text-muted-foreground text-sm">Loading...</span>
);
}
const sampleProducts = [
{ id: "p1", name: "Laptop", price: 999, quantity: 1 },
{ id: "p2", name: "Mouse", price: 29, quantity: 1 },
{ id: "p3", name: "Keyboard", price: 79, quantity: 1 },
];
const addToCart = async (product: Product) => {
const existing = cart.find((p) => p.id === product.id);
if (existing) {
await update({ ...existing, quantity: existing.quantity + 1 });
} else {
await add(product);
}
};
const updateQuantity = async (id: string, delta: number) => {
const item = cart.find((p) => p.id === id);
if (!item) return;
if (item.quantity + delta <= 0) {
await remove(id);
} else {
await update({ ...item, quantity: item.quantity + delta });
}
};
const total = cart.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return (
<div className="flex w-full max-w-md flex-col gap-4">
{/* Products */}
<div className="flex flex-wrap gap-2">
{sampleProducts.map((product) => (
<button
key={product.id}
className="hover:bg-muted rounded-md border px-3 py-1.5 text-sm"
onClick={() => addToCart(product)}
>
{product.name} (${product.price})
</button>
))}
</div>
{/* Cart */}
<div className="rounded-md border">
<div className="bg-muted/50 border-b px-4 py-2">
<span className="text-sm font-medium">
Cart ({cart.length} items)
</span>
</div>
{cart.length === 0 ? (
<p className="text-muted-foreground p-4 text-sm">
Cart is empty
</p>
) : (
<div className="divide-y">
{cart.map((item) => (
<div
key={item.id}
className="flex items-center justify-between px-4 py-2"
>
<span className="text-sm">{item.name}</span>
<div className="flex items-center gap-2">
<button
className="bg-muted rounded px-2 py-0.5 text-xs"
onClick={() =>
updateQuantity(item.id, -1)
}
>
-
</button>
<span className="text-sm">
{item.quantity}
</span>
<button
className="bg-muted rounded px-2 py-0.5 text-xs"
onClick={() =>
updateQuantity(item.id, 1)
}
>
+
</button>
<span className="ml-2 text-sm font-medium">
${item.price * item.quantity}
</span>
</div>
</div>
))}
<div className="bg-muted/50 flex items-center justify-between px-4 py-2">
<span className="text-sm font-medium">
Total: ${total}
</span>
<button
className="text-destructive text-xs hover:underline"
onClick={clear}
>
Clear Cart
</button>
</div>
</div>
)}
</div>
</div>
);
};
Bookmarks with Tags
A bookmarks manager demonstrating complex object updates with arrays:
Try it out!
Add bookmarks and toggle tags. The update function replaces the entire
object, allowing you to modify nested data like tag arrays.
"use client";
import { useIndexedDB } from "@repo/hooks/storage/use-indexed-db";
interface Bookmark {
id: string;
url: string;
title: string;
tags: string[];
}
/* BOOKMARKS - With Tags */
export const Example3 = () => {
const {
data: bookmarks,
add,
remove,
update,
isLoading,
isSupported,
} = useIndexedDB<Bookmark>({
dbName: "bookmarksApp",
storeName: "bookmarks",
keyPath: "id",
});
if (!isSupported) {
return (
<div className="rounded-md border border-yellow-500/50 bg-yellow-500/10 p-4">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
IndexedDB is not supported
</p>
</div>
);
}
if (isLoading) {
return (
<span className="text-muted-foreground text-sm">Loading...</span>
);
}
const sampleBookmarks: Bookmark[] = [
{
id: "1",
url: "https://react.dev",
title: "React Docs",
tags: ["react", "docs"],
},
{
id: "2",
url: "https://nextjs.org",
title: "Next.js",
tags: ["nextjs", "framework"],
},
{
id: "3",
url: "https://github.com",
title: "GitHub",
tags: ["git", "code"],
},
];
const addBookmark = (bookmark: Bookmark) => {
if (bookmarks.some((b) => b.id === bookmark.id)) return;
add(bookmark);
};
const toggleTag = async (bookmark: Bookmark, tag: string) => {
const newTags = bookmark.tags.includes(tag)
? bookmark.tags.filter((t) => t !== tag)
: [...bookmark.tags, tag];
await update({ ...bookmark, tags: newTags });
};
return (
<div className="flex w-full max-w-md flex-col gap-4">
{/* Add Buttons */}
<div className="flex flex-wrap gap-2">
{sampleBookmarks.map((b) => (
<button
key={b.id}
className="hover:bg-muted rounded-md border px-3 py-1.5 text-xs"
onClick={() => addBookmark(b)}
>
+ {b.title}
</button>
))}
</div>
{/* Bookmarks List */}
<div className="flex flex-col gap-2">
{bookmarks.length === 0 ? (
<p className="text-muted-foreground text-sm">
No bookmarks saved
</p>
) : (
bookmarks.map((bookmark) => (
<div
key={bookmark.id}
className="flex flex-col gap-2 rounded-md border p-3"
>
<div className="flex items-center justify-between">
<a
href={bookmark.url}
className="text-primary text-sm font-medium hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{bookmark.title}
</a>
<button
className="text-destructive hover:text-destructive/80"
onClick={() => remove(bookmark.id)}
>
×
</button>
</div>
<div className="flex flex-wrap gap-1">
{["react", "nextjs", "docs", "code"].map(
(tag) => (
<button
key={tag}
className={`rounded-full px-2 py-0.5 text-xs ${
bookmark.tags.includes(tag)
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
onClick={() =>
toggleTag(bookmark, tag)
}
>
{tag}
</button>
),
)}
</div>
</div>
))
)}
</div>
</div>
);
};
How It Works
Database Structure
IndexedDB organizes data in a hierarchy:
Database (dbName: "myApp")
└── Object Store (storeName: "users")
├── Record { id: "1", name: "Alice", ... }
├── Record { id: "2", name: "Bob", ... }
└── Record { id: "3", name: "Charlie", ... }Key Path
The keyPath option defines which property is used as the primary key:
// Using "id" as the key (default)
useIndexedDB<User>({
dbName: "myApp",
storeName: "users",
keyPath: "id", // Each record must have an "id" property
});Version Management
Increment version when you need to modify the store structure:
useIndexedDB<User>({
dbName: "myApp",
storeName: "users",
version: 2, // Triggers onupgradeneeded event
});API Reference
function useIndexedDB<T>(options: UseIndexedDBOptions): UseIndexedDBReturn<T>;Options
| Property | Type | Default | Description |
|---|---|---|---|
dbName | string | - | Name of the IndexedDB database |
storeName | string | - | Name of the object store within the database |
version | number | 1 | Database version (increment to trigger upgrades) |
keyPath | string | "id" | Property used as the primary key for records |
Return Value
| Property | Type | Description |
|---|---|---|
data | T[] | All items currently in the store |
isLoading | boolean | true while database is initializing |
error | string | null | Error message if an operation failed |
isSupported | boolean | false if IndexedDB is unavailable |
add | (item: T) => Promise<IDBValidKey|null> | Insert a new item into the store |
update | (item: T) => Promise<IDBValidKey|null> | Replace an existing item (by key) |
remove | (key: IDBValidKey) => Promise<boolean> | Delete an item by its key |
getByKey | (key: IDBValidKey) => Promise<T|null> | Fetch a single item by key |
clear | () => Promise<boolean> | Remove all items from the store |
refresh | () => Promise<void> | Manually reload data from the store |
When to Use Each Storage Hook
| Scenario | Recommended Hook | Reason |
|---|---|---|
| User preferences (theme) | useLocalStorageState | Simple, synchronous, small data |
| Form data during session | useSessionStorageState | Clears when tab closes |
| Shopping cart | useIndexedDB | Complex objects, survives restarts |
| Offline data cache | useIndexedDB | Large datasets, fast queries |
| User documents/notes | useIndexedDB | Persistent, structured data |
| Temporary filters | useSessionStorageState | Session-scoped, resets on new visit |
Comparison with Other Storage
| Feature | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|
| Storage Limit | ~5MB | ~5MB | GBs (quota-based) |
| Data Types | Strings only | Strings only | Any (objects, blobs) |
| API Style | Synchronous | Synchronous | Asynchronous |
| Persistence | Forever | Until tab closes | Forever |
| Cross-tab | Shared | Isolated | Shared |
| Performance | Fast for small data | Fast for small data | Fast for any size |
Hook Source Code
import { useState, useEffect, useCallback } from "react";
/**
* IndexedDB hook state
*/
export interface UseIndexedDBState<T> {
/** Data retrieved from the store */
data: T[];
/** Whether the database is loading */
isLoading: boolean;
/** Error message if any */
error: string | null;
/** Whether IndexedDB is supported */
isSupported: boolean;
}
/**
* IndexedDB hook return type
*/
export interface UseIndexedDBReturn<T> extends UseIndexedDBState<T> {
/** Add an item to the store */
add: (item: T) => Promise<IDBValidKey | null>;
/** Update an item in the store */
update: (item: T) => Promise<IDBValidKey | null>;
/** Delete an item by key */
remove: (key: IDBValidKey) => Promise<boolean>;
/** Get a single item by key */
getByKey: (key: IDBValidKey) => Promise<T | null>;
/** Clear all items from the store */
clear: () => Promise<boolean>;
/** Refresh data from the store */
refresh: () => Promise<void>;
}
/**
* IndexedDB configuration options
*/
export interface UseIndexedDBOptions {
/** Name of the database */
dbName: string;
/** Name of the object store */
storeName: string;
/** Database version (increment to trigger upgrade) */
version?: number;
/** Key path for the object store */
keyPath?: string;
}
/**
* Opens an IndexedDB database
*/
function openDatabase(
dbName: string,
storeName: string,
version: number,
keyPath: string,
): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onerror = () => {
reject(
new Error(`Failed to open database: ${request.error?.message}`),
);
};
request.onsuccess = () => {
resolve(request.result);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, {
keyPath,
autoIncrement: !keyPath,
});
}
};
});
}
/**
* A React hook for interacting with IndexedDB.
* Provides CRUD operations for storing large amounts of structured data.
*
* @param options - Configuration options for the database
* @returns State and methods for interacting with IndexedDB
*
* @example
* ```tsx
* interface Todo {
* id: string;
* text: string;
* completed: boolean;
* }
*
* const { data, add, remove, isLoading } = useIndexedDB<Todo>({
* dbName: "myApp",
* storeName: "todos",
* keyPath: "id",
* });
* ```
*/
export function useIndexedDB<T>(
options: UseIndexedDBOptions,
): UseIndexedDBReturn<T> {
const { dbName, storeName, version = 1, keyPath = "id" } = options;
const [state, setState] = useState<UseIndexedDBState<T>>({
data: [],
isLoading: true,
error: null,
isSupported: true,
});
const [db, setDb] = useState<IDBDatabase | null>(null);
// Initialize database
useEffect(() => {
if (typeof window === "undefined" || !("indexedDB" in window)) {
setState((s) => ({
...s,
isSupported: false,
isLoading: false,
error: "IndexedDB is not supported",
}));
return;
}
let mounted = true;
(async () => {
try {
const database = await openDatabase(
dbName,
storeName,
version,
keyPath,
);
if (mounted) {
setDb(database);
}
} catch (error) {
if (mounted) {
setState((s) => ({
...s,
isLoading: false,
error:
error instanceof Error
? error.message
: "Failed to open database",
}));
}
}
})();
return () => {
mounted = false;
};
}, [dbName, storeName, version, keyPath]);
// Fetch all data when database is ready
const refresh = useCallback(async () => {
if (!db) return;
setState((s) => ({ ...s, isLoading: true }));
try {
const transaction = db.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName);
const request = store.getAll();
request.onsuccess = () => {
setState((s) => ({
...s,
data: request.result,
isLoading: false,
error: null,
}));
};
request.onerror = () => {
setState((s) => ({
...s,
isLoading: false,
error: request.error?.message ?? "Failed to fetch data",
}));
};
} catch (error) {
setState((s) => ({
...s,
isLoading: false,
error:
error instanceof Error
? error.message
: "Failed to fetch data",
}));
}
}, [db, storeName]);
// Load initial data when db is ready
useEffect(() => {
if (db) {
refresh();
}
}, [db, refresh]);
// Add item
const add = useCallback(
async (item: T): Promise<IDBValidKey | null> => {
if (!db) return null;
return new Promise((resolve) => {
try {
const transaction = db.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
const request = store.add(item);
request.onsuccess = () => {
refresh();
resolve(request.result);
};
request.onerror = () => {
setState((s) => ({
...s,
error:
request.error?.message ?? "Failed to add item",
}));
resolve(null);
};
} catch (error) {
setState((s) => ({
...s,
error:
error instanceof Error
? error.message
: "Failed to add item",
}));
resolve(null);
}
});
},
[db, storeName, refresh],
);
// Update item
const update = useCallback(
async (item: T): Promise<IDBValidKey | null> => {
if (!db) return null;
return new Promise((resolve) => {
try {
const transaction = db.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
const request = store.put(item);
request.onsuccess = () => {
refresh();
resolve(request.result);
};
request.onerror = () => {
setState((s) => ({
...s,
error:
request.error?.message ??
"Failed to update item",
}));
resolve(null);
};
} catch (error) {
setState((s) => ({
...s,
error:
error instanceof Error
? error.message
: "Failed to update item",
}));
resolve(null);
}
});
},
[db, storeName, refresh],
);
// Remove item
const remove = useCallback(
async (key: IDBValidKey): Promise<boolean> => {
if (!db) return false;
return new Promise((resolve) => {
try {
const transaction = db.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
const request = store.delete(key);
request.onsuccess = () => {
refresh();
resolve(true);
};
request.onerror = () => {
setState((s) => ({
...s,
error:
request.error?.message ??
"Failed to delete item",
}));
resolve(false);
};
} catch (error) {
setState((s) => ({
...s,
error:
error instanceof Error
? error.message
: "Failed to delete item",
}));
resolve(false);
}
});
},
[db, storeName, refresh],
);
// Get by key
const getByKey = useCallback(
async (key: IDBValidKey): Promise<T | null> => {
if (!db) return null;
return new Promise((resolve) => {
try {
const transaction = db.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName);
const request = store.get(key);
request.onsuccess = () => {
resolve(request.result ?? null);
};
request.onerror = () => {
setState((s) => ({
...s,
error:
request.error?.message ?? "Failed to get item",
}));
resolve(null);
};
} catch (error) {
setState((s) => ({
...s,
error:
error instanceof Error
? error.message
: "Failed to get item",
}));
resolve(null);
}
});
},
[db, storeName],
);
// Clear all
const clear = useCallback(async (): Promise<boolean> => {
if (!db) return false;
return new Promise((resolve) => {
try {
const transaction = db.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
const request = store.clear();
request.onsuccess = () => {
refresh();
resolve(true);
};
request.onerror = () => {
setState((s) => ({
...s,
error:
request.error?.message ?? "Failed to clear store",
}));
resolve(false);
};
} catch (error) {
setState((s) => ({
...s,
error:
error instanceof Error
? error.message
: "Failed to clear store",
}));
resolve(false);
}
});
}, [db, storeName, refresh]);
return {
...state,
add,
update,
remove,
getByKey,
clear,
refresh,
};
}
export default useIndexedDB;