useIndexedDb
An async IndexedDB-backed store hook with loading state, versioned upgrades, blocked-deadlock handling, quota errors, key-range queries and cross-tab sync.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-indexed-db.jsonPrompt
Build a React + TypeScript "useIndexedDb" hook (React plus one small cross-tab
messaging hook; everything else is the native IndexedDB API).
Contract
- `useIndexedDb<T = unknown>(databaseName: string, options?: {
storeName?: string // default "keyval"
version?: number // default 1, clamped to an integer >= 1
upgrade?: (ctx: { db, transaction, oldVersion, newVersion, storeName }) => void
query?: { prefix?: string; from?: string | number; to?: string | number
limit?: number; direction?: IDBCursorDirection }
sync?: boolean // default true
}): {
status: "opening" | "blocked" | "ready" | "unsupported" | "error"
isReady: boolean
isLoading: boolean
error: Error | null
records: { key: IDBValidKey; value: T }[]
get(key): Promise<T | undefined>
set(key, value): Promise<void>
remove(key): Promise<void>
clear(): Promise<void>
refresh(): Promise<void>
estimate(): Promise<{ usage: number | null; quota: number | null; ratio: number | null }>
}`
- The store uses out-of-line keys (`createObjectStore(name)` with no keyPath), so
a value can be anything structured-cloneable, including a bare Blob or File.
- `status` describes the connection only. A write that fails on quota leaves
`status: "ready"` and puts the reason in `error` — the database is fine, that
one transaction was rolled back.
- Every returned method has a stable identity across renders.
Behavior
- Nothing is synchronous, and the first paint proves it. `isLoading` starts
`true` and `records` starts empty; the initial `status` is the constant
`"opening"` on the server and on the hydration pass, so the render body never
reads a browser global and there is no hydration mismatch. Consumers must
branch on `isLoading` before treating an empty `records` as "no data" — that
confusion is the most common bug written against this API.
- Version upgrades are the caller's responsibility, and the hook says so.
`onupgradeneeded` fires only when the version number changes. Editing the
`upgrade` callback without bumping `version` means the new index is never
created for anyone who already has the database — silently, with no error. The
hook creates the object store if it is missing, then calls `upgrade`
synchronously inside the versionchange transaction; the callback must not
await anything (see the transaction rule below).
- The blocked deadlock has two defences and both are required. An
`indexedDB.open` that needs to upgrade hangs forever — no timeout, no error —
while any other connection to that database is open at an older version.
1. Every connection the hook owns registers `db.onversionchange`, closes
immediately and reconnects one generation later. Without this, two
instances of the hook in the same page asking for v1 and v2 deadlock each
other: the v2 one parks in `blocked` and never leaves. Measured against an
otherwise identical build that only omits this handler: still blocked after
4.0s and never completing; with the handler, v2 reached `ready` in 21-85ms.
2. A connection the hook does not own (a tab running an older build, another
library) can still block the upgrade. Surface it as `status: "blocked"` so
the UI can say "please close the other tabs of this site" instead of
spinning. It is not an error and must not reject — when the other side
closes, the pending open resolves on its own (measured: `ready` 2-62ms after
the blocker closed).
If the reconnect finds a newer stored version (`VersionError`), report a
message telling the user to reload; code running an old schema must never
downgrade one.
- A transaction must contain no await of a non-IDB promise. IDB transactions
auto-commit when the microtask queue drains, so one stray await closes the
transaction and every later call throws `TransactionInactiveError`. Wait for
the connection before opening a transaction, and advance cursors inside the
synchronous `onsuccess`.
- Resolve writes on `transaction.oncomplete`, never on `request.onsuccess`. A
successful request only means "queued". Quota failures arrive as a request
error that aborts the transaction after its requests looked fine, so a promise
settled on success reports a write that was then rolled back.
- Quota errors are translated, not swallowed. `QuotaExceededError` rejects with a
message that names the error, states that the transaction was aborted so
existing records are intact, and points at `estimate()`. Verify it against a
real quota rather than a mock: with the origin quota forced down to 200 KB, a
5 MB blob rejected, the record written earlier still read back unchanged, and
the store accepted writes again once there was room. `DataCloneError` and
`DataError` get the same treatment, each naming what is actually storable.
- Unavailability is a terminal state, not an endless spinner. A missing
`indexedDB` global, and an `indexedDB.open()` that throws synchronously
(Firefox private browsing, sandboxed iframes, "block site data" settings), both
land on `status: "unsupported"` with `isLoading: false` and a readable `error`
within a few milliseconds.
- Queries are key ranges, not filters. `prefix` becomes
`IDBKeyRange.bound(prefix, prefix + "\uffff")`, `from`/`to` become a bound
range, and `limit` simply stops calling `cursor.continue()` — unread records
are never deserialized. `limit: 0` returns an empty array without opening a
transaction; `Infinity` means no limit. Compare the query by value (a
serialized signature), so an inline object literal does not re-read on every
render.
- Cross-tab sync needs a side channel: IndexedDB broadcasts nothing when it
changes. After every successful mutation, post an `invalidate` message on a
`BroadcastChannel` named after the database and store; peers re-run their own
query instead of receiving the data (each tab has a different query, and
cloning a 50 MB blob into every tab would be absurd). Measured propagation
between two real tabs: 2-114ms. `sync: false` opts out of both directions.
- Races and unmounting. Reads carry an incrementing id and are discarded if a
newer read started, if the connection generation changed, or if the component
unmounted. Set `mountedRef` inside the mount effect body, not only in cleanup —
the cleanup-only version is permanently `false` for the surviving instance
under StrictMode's mount then cleanup then mount. Cleanup detaches
`onversionchange` / `onclose` and closes the connection. Verified: an in-flight
write orphaned by an immediate unmount settles instead of hanging, with zero
React warnings and zero unhandled rejections.
- Changing `databaseName` / `storeName` / `version` is a different data source,
so reset `records`, `error` and `status` with a render-phase state adjustment
against a tracked key — not a setState inside an effect body.
Rendering & styling
- The hook renders nothing. Consumers own all UI: use semantic tokens
(`bg-card`, `text-foreground`, `text-muted-foreground`, `text-destructive`,
`border`) for anything that visualizes it, give `blocked` a visible actionable
message rather than a spinner, disable write controls while status is not
`"ready"` instead of hiding them, and format byte counts and timestamps with an
explicit locale (`toLocaleString("en-US")`) so server and client agree.
Customization levers
- Indexes instead of key prefixes — create one in `upgrade`
(`store.createIndex("by-level", "level")`, which requires bumping `version`)
and read through `store.index(name).openCursor(range)`; swap the query shape
from prefix/from/to to `{ index, value }` when records are naturally queried by
a field rather than by key.
- Pagination — keep the last cursor key and pass it as the next `from` with the
same `limit` to get "load more" without re-reading the head of the range.
- Batched writes — add `setMany(entries)` that issues every put inside one
readwrite transaction; faster, atomic, and a quota failure rolls back the whole
batch instead of leaving half of it.
- Value envelope — store `{ value, updatedAt }` instead of the bare value if you
need last-write-wins merging across tabs, and compare `updatedAt` on invalidate
rather than blindly re-reading.
- Eviction policy — pair `estimate()` with a prefix sweep that drops the oldest
records once `ratio` crosses a threshold; browsers do not evict a persisted
origin for you, so nothing is freed unless you free it.
- Persistence — call `navigator.storage.persist()` behind a user gesture to ask
for a durable bucket and show the answer; it changes whether the browser may
clear this data under storage pressure.
- Transport — swap the invalidate message onto a SharedWorker, or a localStorage
write for environments without BroadcastChannel, without touching the rest of
the contract; or set `sync: false` for a store that is intentionally tab-local.Concepts
- Async-first paint — IndexedDB has no synchronous read anywhere, so the first render always carries
isLoading: trueand an empty record list. Treating that frame as "no data" is the classic bug; the empty state belongs behind!isLoading, and the initial status is a constant so the server render and the hydration pass agree. - Versioned schema upgrade —
onupgradeneededruns only when the version number changes, which makes "edited the migration, forgot to bumpversion" a silent no-op for every user who already has the database. The callback runs inside the versionchange transaction and must stay synchronous, because an await there commits the transaction out from under it. - Blocked deadlock and the versionchange yield — an upgrading open hangs indefinitely while any older connection stays open. Connections the hook owns yield by closing on
versionchangeand reconnecting; foreign connections are surfaced asstatus: "blocked"with an actionable message. Without the yield, two instances in one page at v1 and v2 lock each other permanently — measured still blocked after 4.0s, versus 21-85ms to ready with it. - Commit, not queue — a write resolves on
transaction.oncomplete, becauserequest.onsuccessonly means the operation was queued. Quota failures abort the transaction after its requests looked fine, so settling on success would report a write that was rolled back. - Quota is a wall, not a warning — nothing tells you the origin is nearly full;
QuotaExceededErrorfires at the moment of the write and aborts the transaction. That is why the failure is safe (existing records survive) and whyestimate()is the only way to see it coming. - Key-range query — prefix, from/to, limit and direction compile into an
IDBKeyRangeplus a cursor, so only the matching slice is ever deserialized. This is the capability with no localStorage equivalent, alongside storingBlobandFilevalues directly through structured clone. - Cross-tab invalidate — IndexedDB emits no change events, so a write posts a small
invalidatemessage on aBroadcastChanneland every peer re-runs its own query. Broadcasting the value instead would clone large payloads into tabs whose query does not even match them.
useQueryParam
An SSR-safe hook that keeps one piece of component state in a URL query parameter — typed codecs, defaults kept out of the URL, replace by default, and every instance on the page in sync.
useMap
Map-shaped React state: set, setAll, remove, replace and reset over a real Map, with any key type, copy-on-write updates, no-op bail-outs and a never-changing actions object.