1218 文字
6 分
Dexie.js で IndexedDB をまともに使う

はじめに#

ブラウザにはリレーショナルではないものの、構造化データを大量に保存できる 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 を紹介します。

Dexie.js で IndexedDB をまともに使う
https://blog.c12o.net/posts/dexie-indexeddb-guide/
作者
Seu (c12o)
公開日
2026-04-14
ライセンス
CC BY-NC-SA 4.0