react-idb-hooks
v1.0.0
Published
Zero-dependency, TypeScript-first React hooks for IndexedDB. Reactive, cross-tab, and React 16.8 - 19 compatible.
Maintainers
Readme
react-idb-hooks
Reactive IndexedDB hooks for React. Zero runtime dependencies. Works on React 16.8 - 19.
Landing page → · Live demo → — open the demo in two browser tabs to see the cross-tab sync.
npm install react-idb-hookspnpm add react-idb-hooksyarn add react-idb-hooksconst { data } = useIDBQuery(appDb, (db) => db.getAll("todos"), ["todos"]);
const { mutate } = useIDBMutation(appDb, "todos");That's it. No provider. No atom registry. No class to subclass.
Why this library
| | react-idb-hooks | dexie-react-hooks | use-indexeddb | idb (raw) |
|---|---|---|---|---|
| Bundle (gzip) | 2.4 KB | ~65 KB (with Dexie) | ~3 KB | ~3 KB |
| Runtime deps | 0 | Dexie | 0 | 0 |
| Reactive React hooks | yes | yes (useLiveQuery) | partial (no live data) | no (you build it) |
| Cross-tab sync built in | yes (BroadcastChannel) | manual | no | no |
| Schema-typed reads & writes | yes | yes (Dexie ORM) | partial | minimal |
| Migrations API | yes | yes | partial | manual |
| React 16.8 - 19 | yes | yes | partial | n/a |
| SSR-safe (Next, Remix) | yes | with care | with care | with care |
| Last meaningful release | active | active | 2022 | active |
What you get over the alternatives
- Smallest reactive option. Dexie React Hooks is the only other library with live queries today; it pulls in ~65 KB. We do live queries in 2.4 KB by leaning on the React 18+
useSyncExternalStore(vendored shim for 16/17, no extra dep). - Cross-tab is a feature, not a recipe. Open two tabs, mutate in one, the other re-renders. No
BroadcastChannelboilerplate, nostorage-event hacks. - Type-safe but not heavy. Your schema is a single
interface.db.get("todos", id)infers the value, the key, and the index names. No code generation, no runtime declarations. - No magic. Three hooks, function-based query, explicit dep list. The whole library is seven small files - you can read it top-to-bottom in 15 minutes. Easier to maintain, easier to audit.
- Concurrent-mode safe. Native
useSyncExternalStoreon React 18 / 19, vendored fallback on 16 / 17. Strict-Mode double-mount tested.
When not to use this
- You need a full ORM with bulk methods, joins across tables, and a
where(...).between(...)-style query language. Use Dexie. - You need a thin promise wrapper over raw IDB and you'll build reactivity yourself. Use
idb. - You only ever need a single key/value blob. Use
idb-keyvalorlocalStorage.
Live demo
The GitHub Pages site serves a landing page at the root and the Snip Shelf flagship demo at /demo/ — an offline snippet vault with capture+triage cross-tab workflow, Blob attachments, byte-sized treemap, and a v1→v2 schema migration. Open the demo in two browser tabs side-by-side to see the cross-tab BroadcastChannel story unfold.
Two examples live in this repo:
| Example | Purpose | Source |
|---|---|---|
| examples/snip-shelf | Flagship — every feature in a real product surface; deployed to GitHub Pages | src/db.ts · App.tsx |
| examples/minimal-api | The read-everything-in-one-sitting reference: every public hook in ~80 LOC of one-file todos | src/db.ts · App.tsx |
Run them locally:
git clone https://github.com/rully-saputra15/react-idb-hooks.git
cd react-idb-hooks && npm install
cd examples/snip-shelf && npm install && npm run dev # flagship
# or
cd examples/minimal-api && npm install && npm run dev # quickstartThe Pages site is built and deployed automatically by .github/workflows/deploy-pages.yml on every push to main.
First-time setup. In your fork, go to Settings → Pages and set Source to GitHub Actions. Then update two strings: replace
rully-saputra15in this README with your GitHub org/username, and updateBASE_PATHin the workflow file if your repo is not namedreact-idb-hooks.
How to use
1 — Define your database
defineIDB opens (and migrates) one IndexedDB database. Call it once at module scope; subsequent calls with the same (name, version) return the same instance, so it's safe to import everywhere.
// src/db.ts
import { defineIDB } from "react-idb-hooks";
interface AppSchema {
todos: {
value: { id: string; title: string; done: boolean };
key: string;
indexes: { byDone: 0 | 1 };
};
}
export const appDb = defineIDB<AppSchema>({
name: "my-app",
version: 1,
upgrade({ db, oldVersion }) {
if (oldVersion < 1) {
const todos = db.createObjectStore("todos", { keyPath: "id" });
todos.createIndex("byDone", "done");
}
},
});Why a separate
interface? It is your single source of truth for types. The library reads the generic to infer everything: store names, keys, value shapes, index names.upgraderuns once perversionbump - that is where you create stores and indexes at runtime.
2 — Read with useIDBQuery
import { useIDBQuery } from "react-idb-hooks";
import { appDb } from "./db";
function TodoList() {
const { data, status, error } = useIDBQuery(
appDb,
(db) => db.getAll("todos"), // any async fn over the typed db
["todos"], // re-run when these stores change
);
if (status === "loading") return <p>Loading...</p>;
if (status === "error") return <p>{error!.message}</p>;
return <ul>{data!.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}About the third argument.
["todos"]says "this query depends on thetodosstore". When any other component (or any other tab) mutatestodos, this hook re-runs the function and re-renders. That is the entire reactivity model. We chose explicit deps over Proxy-based auto-tracking because they are visible in code review and impossible to misuse.
Inside the function you can call:
db.get("todos", id) // by primary key
db.getAll("todos") // every record
db.getAll("todos", range, 50) // with range + limit
db.byIndex("todos", "byDone", 1)
db.byIndexAll("todos", "byDone", 1)
db.count("todos")
db.getAllKeys("todos")3 — Write with useIDBMutation
import { useIDBMutation } from "react-idb-hooks";
function AddTodo() {
const { mutate, status, error } = useIDBMutation(appDb, "todos");
return (
<button
disabled={status === "pending"}
onClick={() =>
mutate({
type: "put",
value: { id: crypto.randomUUID(), title: "New", done: false },
})
}
>
Add
</button>
);
}mutate resolves after the IDB transaction commits and after the local + cross-tab invalidations have fired. Status transitions: idle → pending → success | error. The promise also rejects on error - use whichever fits your code.
The four operations:
mutate({ type: "put", value: todo }) // upsert
mutate({ type: "add", value: todo }) // insert (rejects on duplicate key)
mutate({ type: "delete", key: id })
mutate({ type: "clear" })4 — Watch the connection with useIDB
import { useIDB } from "react-idb-hooks";
function StatusDot() {
const { status, error } = useIDB(appDb);
// "loading" | "ready" | "error" | "unsupported"
return <Dot color={status === "ready" ? "green" : "amber"} />;
}unsupported means there is no indexedDB in this environment — server render, React Native, Firefox private mode in some configurations.
Errors
Every recoverable IDB error is mapped to a typed class. instanceof works.
import {
IDBVersionError, // schema version conflict
IDBBlockedError, // another tab is holding an old version open
IDBQuotaExceededError, // out of disk
IDBUnsupportedError, // no indexedDB available
} from "react-idb-hooks";
try {
await mutation.mutate({ type: "put", value });
} catch (err) {
if (err instanceof IDBQuotaExceededError) {
/* prune old data, ask the user to clear cache, etc. */
}
}Caveats worth knowing
- SSR.
defineIDBis lazy; it never opens at import time. During an SSR renderuseIDBreports"loading"anduseIDBQueryreportsdata: undefined. Always gate your UI onstatus === "success"or render a skeleton, otherwise you'll get hydration mismatches. In Next.js App Router, mark client files with"use client"and never calldefineIDBfrom a server component. - Safari / iOS. Sometimes refuses to open IDB in private mode and may evict storage under pressure. Surfaced as
IDBUnsupportedErrorandIDBQuotaExceededErrorrespectively — handle them. - Firefox private browsing. IDB is fully disabled. Hooks return
status: "unsupported". - React Native. No
indexedDB. Hooks report"unsupported". Don't import the library on RN-only paths. - React 16 / 17 concurrent tearing. Best-effort. Same vendored shim Facebook ships in
use-sync-external-store/shim. Legacy roots have weaker guarantees than React 18+ concurrent roots.
Bundle size
Measured with npm run size (esbuild minify + gzip):
| Build | Minified | Gzipped | |---|---|---| | Core (every public hook) | 6.1 KB | 2.4 KB |
The useSyncExternalStore shim (~30 LOC) is vendored, so we avoid the use-sync-external-store dependency. React itself is a peer.
Architecture (for the curious)
Seven source files. Strict separation of concerns - any two files share at most one concept.
flowchart LR
types[types.ts] --> db
db[db.ts: open + tx] --> store
crossTab[crossTab.ts: BroadcastChannel] --> store
store[store.ts: subscribe/notify] --> hooks
shim[shim.ts: vendored uSES] --> hooks
hooks[hooks.ts] --> indexBarrel
indexBarrel[index.ts: public API]db.tsis the only file that importsindexedDB.hooks.tsis the only file that imports React.store.tsknows nothing about React or IDB.crossTab.tsknows nothing about React, IDB, or the cache.
Contributing
npm install
npm test # 22 vitest tests, ~2 s
npm run test:types # tsc on test-d/, type-level assertions
npm run build # tsup -> dist/ (ESM + CJS + .d.ts/.d.cts)
npm run size # gzip budget guard (5 KB)CI runs the full Node 18 / 20 / 22 × React 16 / 17 / 18 / 19 matrix on every PR. See .github/workflows/ci.yml.
License
MIT. Portions of src/shim.ts adapted from React's use-sync-external-store, also MIT.
