Hooks

useIndexedDb

An async IndexedDB-backed store hook with loading state, versioned upgrades, blocked-deadlock handling, quota errors, key-range queries and cross-tab sync.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { useBroadcastChannel } from "@/registry/hooks/use-broadcast-channel"

/**
 * Lifecycle of the connection. **It describes the connection only**, not whether
 * the last read or write succeeded (that is `error`): after a write that blew the
 * quota `status` is still `"ready"`, because the database itself is fine — only
 * that one transaction rolled back.
 *
 * - `"opening"` — `indexedDB.open()` is in flight. Always the value on the SSR
 *   and hydration first frame.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-indexed-db.json

Prompt

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: true and 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 upgradeonupgradeneeded runs only when the version number changes, which makes "edited the migration, forgot to bump version" 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 versionchange and reconnecting; foreign connections are surfaced as status: "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, because request.onsuccess only 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; QuotaExceededError fires at the moment of the write and aborts the transaction. That is why the failure is safe (existing records survive) and why estimate() is the only way to see it coming.
  • Key-range query — prefix, from/to, limit and direction compile into an IDBKeyRange plus a cursor, so only the matching slice is ever deserialized. This is the capability with no localStorage equivalent, alongside storing Blob and File values directly through structured clone.
  • Cross-tab invalidate — IndexedDB emits no change events, so a write posts a small invalidate message on a BroadcastChannel and every peer re-runs its own query. Broadcasting the value instead would clone large payloads into tabs whose query does not even match them.

On This Page