Dexie.js で IndexedDB をまともに使う

Dexie.js v4 で IndexedDB を Promise ベースの API とスキーマ管理で扱う方法。class での DB 定義、CRUD、version を積み上げるマイグレーション、where と and の複合クエリ、bulkAdd、容量制限の注意点まで。

· 1,202 字 · 6 分
目次 8 sections
  1. 01 はじめに
  2. 02 生の IndexedDB がつらい理由
  3. 03 Dexie.js のセットアップ
  4. 04 CRUD 操作
  5. 作成
  6. 読み取り
  7. 更新
  8. 削除
  9. 05 スキーマのバージョン管理
  10. 06 実践的なパターン
  11. 複合クエリ
  12. 一括操作
  13. 07 注意点
  14. 08 まとめ

はじめに#

ブラウザには、構造化データを大量に保存できる 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 のセットアップ#

Terminal window
$ pnpm add dexie

データベースの定義は class ベースで書きます。

db.ts
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 を紹介します。

記事一覧