npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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

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-idb

For 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