@inevitable_divu/use-idb
v1.3.0
Published
Zero-dependency, high-performance IndexedDB reactive state engine for React 19 & TypeScript. Features microtask write coalescing, AES-GCM (NIST AAD) encryption, cross-tab synchronization, and full Suspense support.
Downloads
857
Maintainers
Readme
use-idb
Typed, reactive IndexedDB for React 18+ and TypeScript. Build browser-persistent features with a schema-first database API, live React hooks, typed queries, and optional encryption—without maintaining a separate state cache yourself.
- Quick start: install the package and build a typed, reactive list in a few minutes.
- Full guide: schema design, CRUD, querying, hooks, configuration, migrations, encryption, and operational behavior are covered below.
- Runnable examples: see the examples directory and examples guide.
- Release notes: see the changelog.
What this is: a client-side storage and React reactivity layer built on IndexedDB. It is not a server database, a cloud-sync service, or a conflict-resolution system. Your application remains responsible for authentication, server synchronization, validation, and multi-writer conflict handling.
Contents
- Why use-idb
- Installation
- Quick start
- Simple key-value state
- Define a database and stores
- CRUD and transactions
- Queries
- React hooks
- Runtime configuration
- Schema migrations
- Encryption and security
- Storage, lifecycle, and performance
- Troubleshooting
- API overview
- License
Why use-idb
IndexedDB is asynchronous and useful for persistent browser data, but connecting it to a React UI usually requires extra code for caching, loading states, subscriptions, invalidation, and concurrent writes. The package brings those pieces together.
- Typed schemas: infer store names, record types, keys, and index values from your definitions.
- Reactive reads: useRecord and useLiveQuery subscribe to cached data through React's useSyncExternalStore.
- Committed writes: write promises resolve after the underlying storage transaction commits; failed writes reject.
- Batched writes: compatible writes queued in the same microtask can share a transaction.
- IndexedDB queries: fluent filters, indexes, key ranges, cursor direction, pagination, projections, and query-plan inspection.
- Cross-tab invalidation: tabs notify each other that data changed; each tab refreshes from its own database.
- Optional AES-GCM middleware: encrypt selected fields using the Web Crypto API.
- Pluggable infrastructure: custom storage, sync, and lock adapters; bounded cache configuration; diagnostics; optional memory fallback.
- Rich values: the built-in serializer supports values including Date, BigInt, Map, Set, ArrayBuffer, typed arrays, DataView, undefined, and special numeric values.
The package has root, ./react, and ./core exports. React is a peer dependency (react >=18).
Installation
Choose your package manager:
npm install @inevitable_divu/use-idb
# or
pnpm add @inevitable_divu/use-idb
# or
yarn add @inevitable_divu/use-idbFor React hooks, your application must also have React installed. This package ships TypeScript declarations.
Quick start
This example creates a typed todos store and a React component that automatically refreshes when matching records change. It uses the public package export and can be adapted to a Vite, Next.js, or other React application.
1. Define the database
Create src/db.ts:
import { createDatabase, createDatabaseHooks, store } from "@inevitable_divu/use-idb";
export interface Todo {
id: string;
title: string;
done: boolean;
createdAt: Date;
}
export const db = createDatabase({
name: "my-app",
version: 1,
stores: {
todos: store<Todo>()({
keyPath: "id",
indexes: {
by_done: "done",
by_created: "createdAt",
},
}),
},
});
export const { useLiveQuery, useMutation, useRecord, useIDBStatus } = createDatabaseHooks(db);The name identifies this database in the browser, and version controls schema upgrades. Store definitions are persistent schema: changing the primary key configuration of an existing store requires a migration (see Schema migrations).
2. Use it in a component
Create src/TodoList.tsx:
import { useLiveQuery, useMutation } from "./db";
export function TodoList() {
const {
data: todos = [],
isLoading,
error,
refetch,
} = useLiveQuery(
(database) =>
database.query("todos").where("by_done").equals(false).orderBy("createdAt", "desc").toArray(),
[],
);
const { put, deleteRecord, isPending } = useMutation("todos");
async function addTodo() {
await put({
id: crypto.randomUUID(),
title: "Read the use-idb guide",
done: false,
createdAt: new Date(),
});
}
if (isLoading) return <p>Loading todos…</p>;
if (error) return <p role="alert">Could not load todos: {error.message}</p>;
return (
<section>
<button disabled={isPending} onClick={() => void addTodo().catch(console.error)}>
Add todo
</button>
<button onClick={() => void refetch()}>Refresh</button>
{todos.length === 0 ? (
<p>No open todos yet.</p>
) : (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title}
<button
disabled={isPending}
onClick={() => void deleteRecord(todo.id).catch(console.error)}
>
Delete
</button>
</li>
))}
</ul>
)}
</section>
);
}That's the basic workflow: define a store, query it in a hook, and write through a mutation. The live query tracks its store dependencies and re-renders after local changes and supported cross-tab invalidations. Production applications should handle mutation failures with their own user-facing error state instead of only logging them.
Simple key-value state
For a single preference or other small key-value use case, you can use useIdb without defining a schema or hook factory:
import { useIdb } from "@inevitable_divu/use-idb";
export function ThemeToggle() {
const {
value: theme = "light",
setValue,
loading,
error,
} = useIdb<"light" | "dark">("app-theme", { defaultValue: "light" });
if (error) return <p role="alert">{error.message}</p>;
return (
<button
disabled={loading}
onClick={() =>
void setValue((current) => (current === "dark" ? "light" : "dark")).catch(console.error)
}
>
Theme: {theme}
</button>
);
}By default, useIdb uses the shared database named use_idb_default and the keyval object store. You can specify another storeName. Its result contains value, setValue, remove, loading, and error.
Use the schema-based API for applications with multiple entities, indexes, typed queries, migrations, or more explicit storage configuration.
Define a database and stores
Inline primary keys
Use store<T>()({...}) to declare a record shape and its key path. Index aliases can be simple field names or objects with index options.
import { createDatabase, store } from "@inevitable_divu/use-idb";
interface User {
id: string;
email: string;
role: "admin" | "member";
tags: string[];
createdAt: Date;
}
export const db = createDatabase({
name: "users-db",
version: 1,
stores: {
users: store<User>()({
keyPath: "id",
indexes: {
by_email: { keyPath: "email", unique: true },
by_role: "role",
by_tag: { keyPath: "tags", multiEntry: true },
by_created: "createdAt",
},
}),
},
});- keyPath identifies the primary key on each record. For a compound key, use a tuple such as ["workspaceId", "userId"].
- indexes defines the secondary indexes you can query. Use unique: true when duplicates should be rejected.
- multiEntry: true indexes each item in an array-valued property; it cannot be combined with a compound index key path.
- Use stable database names and increase version when you need a schema upgrade.
Out-of-line primary keys
Use outOfLineStore<TItem, TKey>()({...}) when the key is passed separately and is not stored on the record itself:
import { createDatabase, outOfLineStore } from "@inevitable_divu/use-idb";
interface AuditEvent {
type: "created" | "updated";
occurredAt: Date;
}
const auditDb = createDatabase({
name: "audit-db",
version: 1,
stores: {
events: outOfLineStore<AuditEvent, string>()({
indexes: { by_type: "type" },
}),
},
});
await auditDb.put("events", { type: "created", occurredAt: new Date() }, "event-1");
const event = await auditDb.get("events", "event-1");CRUD and transactions
The database instance exposes typed storage operations. These examples assume db has a users store.
// Insert or replace a record. Inline-key stores read the key from the record.
await db.put("users", {
id: "u-1",
email: "[email protected]",
role: "admin",
tags: ["engineering"],
createdAt: new Date(),
});
// Read one record; a missing key returns undefined.
const user = await db.get("users", "u-1");
// Delete one record or clear the whole store.
await db.delete("users", "u-1");
await db.clear("users");
// Read matching records through the query builder.
const admins = await db.query("users").where("by_role").equals("admin").toArray();A write resolves after the owning storage transaction commits. If the transaction aborts, the write rejects and committed cache state is not published.
Atomic operations across stores
Use db.transaction when a set of changes must succeed or fail together:
// Assumes db has "inventory" and "orders" stores.
interface InventoryItem {
id: string;
remaining: number;
}
await db.transaction(["inventory", "orders"], "readwrite", async (tx) => {
const item = await tx.get<InventoryItem>("inventory", "item-1");
if (!item || item.remaining < 1) {
throw new Error("Item is unavailable");
}
await tx.put("inventory", { ...item, remaining: item.remaining - 1 });
await tx.put("orders", {
id: crypto.randomUUID(),
itemId: "item-1",
placedAt: new Date(),
});
});Keep transaction callbacks focused on database work. IndexedDB transactions can become inactive while waiting for network requests, timers, or unrelated asynchronous tasks. Do not use this as a place to await remote API calls.
Middleware limitation: explicit db.transaction() is rejected with TransactionMiddlewareUnsupportedError when middleware is configured. This prevents async middleware—such as Web Crypto encryption—from being silently bypassed or awaited unsafely inside a live IndexedDB transaction. For middleware-enabled databases, use the normal middleware-aware CRUD and batch operations or redesign the operation boundary.
Synchronous record updates
For a database without middleware, db.update(storeName, key, updater) performs a short readwrite transaction with a synchronous updater. Keep the updater pure and synchronous; it can fail if the transaction aborts. This helper is unavailable when middleware is configured.
Queries
Queries use IndexedDB stores, indexes, and cursors where possible. The fluent builder is typed from the store definition.
// Exact index match.
const admins = await db.query("users").where("by_role").equals("admin").toArray();
// A range on an index.
const recentUsers = await db
.query("users")
.where("by_created")
.above(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
.toArray();
// Filter query results further.
const taggedAdmins = await db
.query("users")
.where("by_role")
.equals("admin")
.filter((user) => user.tags.includes("engineering"))
.toArray();
// Sort, paginate, and count.
const firstPage = await db
.query("users")
.orderBy("createdAt", "desc")
.offset(0)
.limit(20)
.toArray();
const totalAdmins = await db.query("users").where("by_role").equals("admin").count();
const firstAdmin = await db.query("users").where("by_role").equals("admin").first();
// Return only selected fields.
const summaries = await db.query("users").select("id", "email").toArray();Query operators
Use where(fieldOrIndexName) followed by one of:
- Matching: equals(value), notEquals(value), anyOf(values)
- Ranges: gt(value), gte(value), lt(value), lte(value), between(lower, upper, includeLower?, includeUpper?), above(value), below(value), startsWith(prefix)
- Composition and post-processing: and(fieldOrIndexName), filter(predicate), orderBy(field, "asc" | "desc")
- Traversal and results: direction("next" | "nextunique" | "prev" | "prevunique"), offset(count), limit(count), select(...fields), toArray(), first(), count(), explain()
Prefer indexed fields for high-volume filtering. JavaScript filter predicates and some sort/pagination combinations may require reading or sorting results in memory. explain() reports the selected access path and whether filtering, sorting, or pagination requires post-processing.
React hooks
Create hooks once at module scope with createDatabaseHooks(db). The factory binds store names, record types, and key types to the database configuration.
useRecord(storeName, key, options?)
Subscribe to one record. It returns data, isLoading, isFetching, error, set(value), patch(partial), and remove().
function UserName({ id }: { id: string }) {
const { data: user, isLoading, patch, error } = useRecord("users", id);
if (isLoading) return <p>Loading…</p>;
if (error) return <p role="alert">{error.message}</p>;
if (!user) return <p>User not found</p>;
return (
<button onClick={() => void patch({ role: "member" }).catch(console.error)}>
{user.email} — change role
</button>
);
}set replaces the record; patch applies a shallow patch to an existing record; remove deletes it. The optional optimistic setting controls whether the cache is updated before the write commits. The hook does not create a missing record just because you call patch.
useLiveQuery(queryFn, deps, options?)
Subscribe to a query and re-render when tracked data changes. The result contains data, isLoading, isFetching, error, and refetch().
const result = useLiveQuery(
(database) => database.query("users").where("by_role").equals(role).toArray(),
[role],
);Treat the callback as a pure query description. It may be invoked more than once as dependencies are inspected and data is fetched. Do not perform writes, analytics, or other side effects inside it.
For custom asynchronous work, pass an explicit stores list so changes to those stores invalidate the query. The factory-bound hook can run custom async work as well:
const { data, error } = useLiveQuery(
async () => {
const response = await fetch("/api/suggestions");
if (!response.ok) throw new Error("Could not load suggestions");
return response.json() as Promise<string[]>;
},
[],
{ stores: ["users"], queryKey: "user-suggestions" },
);The unbound useLiveQuery export also accepts a second callback context with an AbortSignal for cancellable async work. When it must use a particular database instance, render the component below IndexedDBProvider.
Other options include initialData, enabled, queryKey, and suspense. Use queryKey to distinguish otherwise similar queries when captured state affects results.
useMutation(storeName)
Provides put, bulkPut, deleteRecord, remove, and clearStore, together with isPending and error. The factory-generated mutation hook is scoped to one store. bulkPut writes in chunks; its default chunk size is 500 records.
useIDBStatus()
Read storage and connection status, including connectionState, isFallbackMode, fallbackReason, isUpgradeBlocked, requiresReloadForUpgrade, and quota/persistence information. The result also includes refreshQuota() and reconnect().
Use status in your UI when persistence matters. A write to in-memory fallback storage is not equivalent to durable IndexedDB persistence.
React Suspense and context
useLiveQuery supports an opt-in { suspense: true } option. Place the component below a React <Suspense fallback={...}> boundary. Suspense is optional; the ordinary loading/error result contract remains available for non-Suspense use.
IndexedDBProvider and useDatabase are available for applications that want to supply/read an engine through context. When using createDatabaseHooks(db), the hooks are already bound to that instance.
Runtime configuration
The recommended configuration separates the persistent schema from runtime behavior:
import { createDatabase, store } from "@inevitable_divu/use-idb";
interface Draft {
id: string;
title: string;
}
const db = createDatabase({
schema: {
name: "drafts-db",
version: 1,
stores: {
drafts: store<Draft>()({ keyPath: "id" }),
},
},
cache: {
maxEntries: 1000,
ttlMs: 60_000,
},
writes: {
coalesceWindowMs: 0,
},
crossTab: {
enabled: true,
},
fallback: {
strategy: "error",
requestPersistentStorage: true,
},
diagnostics: {
onEvent(event) {
console.info("use-idb", event.type);
},
onError(error) {
console.error("use-idb", error);
},
},
});All groups are optional. Important settings:
| Option | Purpose | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | adapters | Supply custom storage, sync, or lock adapters. | | middleware | Ordered middleware such as serialization or field encryption. | | cache.maxEntries, cache.ttlMs | Cache capacity and time-to-live. Defaults: 1,000 entries and 60,000 ms. Active subscriptions can keep the cache above the soft capacity. | | writes.coalesceWindowMs | Write coalescing window in milliseconds. Defaults to 0, which coalesces writes queued in the same microtask. | | crossTab.enabled | Enable cross-tab invalidation; enabled by default. | | fallback.strategy | "memory" to allow volatile in-memory fallback, or "error" to fail if IndexedDB cannot be used. | | fallback.requestPersistentStorage | Ask the browser for persistent storage on a best-effort basis; the browser decides whether to grant it. | | diagnostics | Optional event/error callbacks. Exceptions thrown by diagnostics callbacks do not change storage outcomes. |
The backward-compatible form createDatabase(schema, { middlewares: [...] }) is also supported. In the grouped runtime API the option is named middleware (singular).
Schema and migration failures are not silently converted into memory fallback. For applications where durability is required, consider fallback: { strategy: "error" } and expose storage status to users.
Schema migrations
Increase the database version when adding or changing stores and indexes. Migrations run during the synchronous IndexedDB version-change transaction.
import { createDatabase, store } from "@inevitable_divu/use-idb";
interface Profile {
id: string;
fullName: string;
displayName?: string;
}
const db = createDatabase({
name: "profiles-db",
version: 2,
stores: {
profiles: store<Profile>()({
keyPath: "id",
indexes: { by_display_name: "displayName" },
}),
},
migrations: {
2: ({ transaction }) => {
const profiles = transaction.objectStore("profiles");
const request = profiles.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) return;
const profile = cursor.value as Profile;
if (profile.displayName === undefined) {
cursor.update({ ...profile, displayName: profile.fullName });
}
cursor.continue();
};
},
},
});Key rules:
- Keep migrations synchronous in the IndexedDB sense: use requests/cursors inside the version-change transaction; do not await network calls or unrelated promises.
- Index definitions can be reconciled during an upgrade, but changing a store's primary keyPath or autoIncrement setting requires an explicit migration that rebuilds the store and copies the existing data.
- Test upgrades from every schema version you support, not only a fresh database.
- If another open tab prevents an upgrade, close/reload that tab and inspect useIDBStatus() for blocked-upgrade status.
Encryption and security
createEncryptionMiddleware can encrypt selected fields with AES-GCM through the browser's Web Crypto API. It supports key rings so older ciphertext can be read while new writes use a new primary key.
import { createDatabase, createEncryptionMiddleware, store } from "@inevitable_divu/use-idb";
interface PrivateNote {
id: string;
title: string;
secret: string;
}
export function createPrivateNotesDatabase(encryptionKey: CryptoKey) {
return createDatabase(
{
name: "private-notes",
version: 1,
stores: {
notes: store<PrivateNote>()({ keyPath: "id" }),
},
},
{
middlewares: [createEncryptionMiddleware(encryptionKey, ["secret"])],
},
);
}Security responsibilities remain with the application:
- Manage keys separately from the encrypted records. Do not store the encryption key beside the ciphertext.
- Plan key persistence, recovery, rotation, and loss behavior. An in-memory non-extractable key will not automatically survive a reload.
- Encryption protects selected stored fields; it does not secure a compromised application runtime or replace authorization, XSS defenses, or server-side access controls.
- Ciphertext is bound to store, field, and primary key when the key is available. For auto-generated keys that are not known before encryption, that per-record binding cannot provide the same protection against ciphertext being moved between records.
- Middleware uses asynchronous transformations, so explicit db.transaction() and db.update() are intentionally unavailable with middleware configured. Use the regular middleware-aware mutation methods.
See encrypted private notes for a fuller example.
Storage, lifecycle, and performance
Browser storage and memory fallback
IndexedDB is browser-managed storage and can be cleared by the user, browser settings, eviction, private browsing restrictions, or test environments. It is not a backup system.
If configured to fall back to memory, the package uses volatile, process-local storage. Data in that mode is not durable across reloads and is not shared between tabs. Check useIDBStatus().isFallbackMode and fallbackReason before telling users that changes are safely persisted. Use fallback.strategy: "error" when silently switching to volatile storage would be unacceptable.
The persistent-storage request is best effort. A request does not guarantee that a browser will grant persistence or retain data indefinitely.
Cross-tab behavior
Cross-tab messages are versioned invalidation hints, not a replication log. A receiving tab refreshes from its own IndexedDB database; messages do not contain records and do not resolve conflicting simultaneous edits. Cross-tab invalidation is disabled while using isolated in-memory fallback storage.
Cache, optimistic updates, and writes
The hooks use a synchronous cache snapshot to integrate with React rendering. Mutations can optimistically update this snapshot; the promise still indicates whether storage committed. Failed writes reject and optimistic changes are rolled back. Disable optimistic behavior for supported hooks if the UI should wait for persistence.
Compatible writes queued in the same microtask may be combined into a transaction. This reduces overhead for some workloads, but throughput and latency depend on the browser, data size, indexes, and access pattern; the package does not promise a fixed performance improvement.
Server rendering
IndexedDB is a browser API. During server rendering, hooks use server-safe snapshots rather than reading browser storage. Render an appropriate fallback and let the client hydrate/read persisted data. Do not treat IndexedDB contents as available to server components or as a server-side data store.
Troubleshooting
| Symptom | What to check | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | Data seems to disappear after reload | Check whether the engine fell back to memory, whether the browser cleared storage, and whether the database name/version changed. | | An upgrade is blocked | Close other tabs running an older connection, then reload as needed. Inspect isUpgradeBlocked and requiresReloadForUpgrade. | | A query does not rerun when captured state changes | Include relevant values in the deps array and use queryKey when distinct query identities need to be represented. | | A live-query callback seems to run more than once | This is part of dependency discovery and fetching. Keep the callback pure and move side effects outside it. | | Writes fail with middleware enabled | Use CRUD/batch APIs. Explicit transactions and db.update() cannot run with middleware. | | TypeScript rejects a field or key | Define stores with store<T>()({...}) or outOfLineStore<TItem, TKey>()({...}) and query declared fields/indexes. | | React UI does not reflect a custom async query | Declare affected stores in options.stores and provide a stable queryKey when needed. Use the abort signal for cancellable work. |
API overview
| API | Purpose | | --------------------------------------------------------- | --------------------------------------------------------------- | | createDatabase(schema) | Create or retrieve an engine for a typed database schema. | | store<T>()({...}) | Define a typed store with an inline primary key. | | outOfLineStore<TItem, TKey>()({...}) | Define a typed store whose primary key is passed separately. | | createDatabaseHooks(db) | Create schema-bound React hooks. | | useIdb(key, options?) | Simple reactive key-value hook backed by the default database. | | useRecord(store, key, options?) | Subscribe to one record and set, patch, or remove it. | | useLiveQuery(queryFn, deps, options?) | Subscribe to query results. | | useMutation(store) | Perform store-scoped writes, batch writes, deletes, and clears. | | useIDBStatus() | Read connection, fallback, upgrade, and storage quota status. | | db.get/put/delete/clear(...) | Core CRUD operations. | | db.query(store) | Build a typed IndexedDB query. | | db.transaction(stores, mode, callback) | Run explicit atomic transaction work (without middleware). | | createEncryptionMiddleware(key, fields) | Encrypt selected fields through Web Crypto. | | IndexedDBProvider / useDatabase | Provide or access an engine through React context. |
Imports are available from @inevitable_divu/use-idb; React-only APIs are also available from @inevitable_divu/use-idb/react, and core engine APIs from @inevitable_divu/use-idb/core.
Examples and release notes
The examples directory covers typed CRUD, live React lists, multi-entry indexes, offline outboxes, compound keys, schema migration, atomic multi-store updates, field encryption, cross-tab invalidation, and bulk import/export. Start with the examples guide.
For release-specific changes and migration notes, see the changelog. Bugs and feature requests belong in GitHub Issues.
License
MIT © Divyansh Pandey
