はじめに
ブラウザにはリレーショナルではないものの、構造化データを大量に保存できる IndexedDB が組み込まれています。
PWA やローカルファーストなアプリでは避けて通れないストレージですが、
生の API はコールバックベースで冗長、トランザクション管理が煩雑、型の恩恵もありません。
Dexie.js は IndexedDB の上に Promise ベースの薄いラッパーを提供するライブラリです。
TypeScript との相性がよく、スキーマのバージョン管理も宣言的に書けます。
この記事では Dexie.js v4 の導入から日常的な CRUD、スキーマ移行までを紹介します。
生の IndexedDB がつらい理由
IndexedDB の API をそのまま使うと、データベースのオープンだけでこれだけのコードが必要です。
const request = indexedDB.open("mydb", 1);request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains("tasks")) { db.createObjectStore("tasks", { keyPath: "id", autoIncrement: true }); }};request.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; // ここからトランザクションを開いて操作...};request.onerror = (event) => { console.error("DB open failed", event);};イベントハンドラのネスト、型の不在、エラーハンドリングの散在。
localStorage の手軽さと比べると、導入コストが高すぎます。
Dexie.js のセットアップ
$ pnpm add dexieデータベースの定義は class ベースで書きます。
import Dexie, { type EntityTable } from "dexie";
interface Task { id: number; title: string; done: boolean; createdAt: Date;}
const db = new Dexie("taskdb") as Dexie & { tasks: EntityTable<Task, "id">;};
db.version(1).stores({ tasks: "++id, done, createdAt",});
export { db };export type { Task };"++id, done, createdAt" はインデックスの定義です。
++ は自動採番の主キー、カンマ区切りでセカンダリインデックスを追加します。
ここに書かなかったプロパティもデータとしては保存されますが、インデックスで検索できないだけです。
CRUD 操作
Dexie の API は直感的です。Promise を返すので async/await でそのまま書けます。
作成
await db.tasks.add({ title: "記事を書く", done: false, createdAt: new Date(),});読み取り
// 全件取得const allTasks = await db.tasks.toArray();
// 条件付き取得const pending = await db.tasks.where("done").equals(false).toArray();
// 主キーで取得const task = await db.tasks.get(1);更新
await db.tasks.update(1, { done: true });削除
await db.tasks.delete(1);生の IndexedDB ではトランザクションのオープン、オブジェクトストアの取得、リクエストの発行、イベントハンドラの登録…
と 4 ステップ必要だった操作が、1 行で完結します。
スキーマのバージョン管理
アプリの進化に伴い、テーブル構造を変更したくなるのは必然です。
Dexie はバージョンを積み上げる宣言的な移行をサポートしています。
db.version(1).stores({ tasks: "++id, done, createdAt",});
db.version(2).stores({ tasks: "++id, done, createdAt, priority",});
db.version(3) .stores({ tasks: "++id, done, createdAt, priority", tags: "++id, name", }) .upgrade((tx) => { return tx .table("tasks") .toCollection() .modify((task) => { task.priority = task.priority ?? "medium"; }); });バージョン 2 で priority インデックスを追加し、バージョン 3 で tags テーブルを追加しつつ既存データにデフォルト値を埋めています。
upgrade コールバックでデータマイグレーションも書けるのがポイントです。
過去のバージョン定義は消さずに残す必要があります。
ユーザーがバージョン 1 のまま久しぶりにアクセスした場合、Dexie は 1 → 2 → 3 と順番にマイグレーションを実行してくれます。
実践的なパターン
複合クエリ
const recentPending = await db.tasks .where("done") .equals(false) .and((task) => task.createdAt > oneWeekAgo) .sortBy("createdAt");where でインデックスを使った高速フィルタリングを行い、and で追加条件を適用するパターンです。
and の条件は where の結果に対する JS フィルタになるため、先に where で絞り込んで対象を減らす順序が重要です。
一括操作
await db.tasks.bulkAdd([ { title: "タスク A", done: false, createdAt: new Date() }, { title: "タスク B", done: false, createdAt: new Date() }, { title: "タスク C", done: false, createdAt: new Date() },]);
await db.tasks.where("done").equals(true).delete();bulkAdd は個別の add を繰り返すより大幅に高速です。
内部的に 1 つのトランザクションでまとめて書き込みます。
注意点
-
容量制限はブラウザ依存
Chrome はオリジンごとにディスク空き容量の一定割合(目安で数百 MB〜数 GB)まで。
Safari 17 以降も同様にディスクベースの上限が適用されます。
ただし、ユーザーがストレージをクリアすればデータは消えるため、永続化が必須ならサーバーとの同期を別途設計する必要があります。 -
Web Worker からもアクセス可能
IndexedDB はメインスレッドと Web Worker の両方から使えます。
重い集計処理は Worker に逃がすと UI がブロックされません。 -
DevTools で中身を確認できる
Chrome DevTools の Application タブ → IndexedDB からテーブルの中身をリアルタイムで確認できます。
デバッグ時に重宝します。
まとめ
- IndexedDB はブラウザ内蔵の強力なストレージだが、生の API は実用にはつらすぎる
- Dexie.js を入れれば Promise ベースの直感的な API で CRUD が 1 行で書ける
- スキーマのバージョン管理が宣言的に書けるため、アプリの進化に追従しやすい
- ローカルファーストなアプリや PWA のデータ層として、導入コストに対するリターンが大きい
次の記事では、IndexedDB とは異なるアプローチでブラウザ内にリレーショナル DB を持ち込む SQLite WASM + OPFS を紹介します。