IndexedDB 実践ガイド:コピペで使えるCRUD・トランザクション・検索パターン
IndexedDB はブラウザに搭載された NoSQL 型のデータベースです。localStorage と違って大容量のデータを扱え、インデックスによる検索やトランザクションもサポートしています。ただし生の API はコールバックベースで書きにくいため、この記事では実践的なコピペコードを中心にまとめます。
IndexedDB の基本を10秒で理解する
| 概念 | 説明 |
|---|---|
| Database | 1オリジンに複数作成可能。バージョン番号を持つ |
| Object Store | RDBでいうテーブル。key-value でレコードを保存 |
| Index | Object Store 内の特定フィールドで検索するための索引 |
| Transaction | 読み書きの単位。readonly / readwrite を指定 |
容量の目安は空きディスクの数十%程度(ブラウザ依存)で、localStorage の 5MB 制限とは桁違いに使えます。
生の IndexedDB API(イベントベース)
まずは素の API です。Promise 化されていないため慣れないと書きにくいですが、仕組みを理解するために載せておきます。
function openDB(name: string, version: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, version);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains("users")) {
const store = db.createObjectStore("users", { keyPath: "id" });
store.createIndex("by-email", "email", { unique: true });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
onupgradeneeded はDBのバージョンが上がったとき(初回作成時含む)だけ呼ばれます。スキーマ変更はここでしか行えません。
素のAPIで1件追加する場合:
function addUser(db: IDBDatabase, user: { id: string; name: string; email: string }) {
return new Promise<void>((resolve, reject) => {
const tx = db.transaction("users", "readwrite");
tx.objectStore("users").add(user);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
このように1操作ごとにPromiseラップが必要で、実務では次に紹介する idb ライブラリを使うのが圧倒的に楽です。
idb ライブラリで書きやすくする(推奨)
idb は IndexedDB を Promise ベースの薄いラッパーにするライブラリです。型定義も充実しており、実務ではまずこれを使えば十分です。
npm install idb
スキーマ定義と初期化
import { openDB, DBSchema, IDBPDatabase } from "idb";
interface MyDB extends DBSchema {
users: {
key: string;
value: { id: string; name: string; email: string; createdAt: number };
indexes: { "by-email": string; "by-createdAt": number };
};
posts: {
key: number;
value: { id?: number; userId: string; title: string; body: string };
indexes: { "by-userId": string };
};
}
let dbPromise: Promise<IDBPDatabase<MyDB>> | null = null;
function getDB() {
if (!dbPromise) {
dbPromise = openDB<MyDB>("my-app-db", 1, {
upgrade(db) {
const userStore = db.createObjectStore("users", { keyPath: "id" });
userStore.createIndex("by-email", "email", { unique: true });
userStore.createIndex("by-createdAt", "createdAt");
const postStore = db.createObjectStore("posts", {
keyPath: "id",
autoIncrement: true,
});
postStore.createIndex("by-userId", "userId");
},
});
}
return dbPromise;
}
DB接続はシングルトンにしておくと、呼び出し側で毎回 openDB を意識しなくて済みます。
CRUD 操作
// Create
async function addUser(user: { id: string; name: string; email: string }) {
const db = await getDB();
await db.add("users", { ...user, createdAt: Date.now() });
}
// Read(1件)
async function getUser(id: string) {
const db = await getDB();
return db.get("users", id);
}
// Read(全件)
async function getAllUsers() {
const db = await getDB();
return db.getAll("users");
}
// Update(存在すれば上書き、なければ作成)
async function upsertUser(user: { id: string; name: string; email: string; createdAt: number }) {
const db = await getDB();
await db.put("users", user);
}
// Delete
async function deleteUser(id: string) {
const db = await getDB();
await db.delete("users", id);
}
add はキー重複時にエラー、put は upsert(挿入 or 上書き)という違いに注意してください。
インデックスで検索する
// email で1件検索(unique index)
async function findUserByEmail(email: string) {
const db = await getDB();
return db.getFromIndex("users", "by-email", email);
}
// userId に紐づく投稿を全件取得
async function getPostsByUser(userId: string) {
const db = await getDB();
return db.getAllFromIndex("posts", "by-userId", userId);
}
// 作成日時で範囲検索(IDBKeyRange を使う)
async function getUsersCreatedAfter(timestamp: number) {
const db = await getDB();
const range = IDBKeyRange.lowerBound(timestamp);
return db.getAllFromIndex("users", "by-createdAt", range);
}
IDBKeyRange には lowerBound / upperBound / bound(範囲指定)/ only(完全一致)があります。全件取得より圧倒的に高速なので、フィルタ条件がある場合は必ずインデックス経由にしましょう。
トランザクションで複数操作をまとめる
複数ストアにまたがる書き込みを1つのトランザクションで原子的に行いたい場合:
async function createUserWithFirstPost(
user: { id: string; name: string; email: string },
postTitle: string
) {
const db = await getDB();
const tx = db.transaction(["users", "posts"], "readwrite");
await Promise.all([
tx.objectStore("users").add({ ...user, createdAt: Date.now() }),
tx.objectStore("posts").add({ userId: user.id, title: postTitle, body: "" }),
tx.done,
]);
}
tx.done を Promise.all に含めることで、全操作の完了を待ってからトランザクションを閉じられます。途中でエラーが起きると自動的にロールバックされます。
カーソルで大量データを1件ずつ処理する
getAll() は全件をメモリに乗せるため、件数が多い場合はカーソルで逐次処理します。
async function deleteOldPosts(beforeTimestamp: number) {
const db = await getDB();
const tx = db.transaction("posts", "readwrite");
let cursor = await tx.store.openCursor();
while (cursor) {
if (cursor.value.userId && (cursor.value as any).createdAt < beforeTimestamp) {
await cursor.delete();
}
cursor = await cursor.continue();
}
await tx.done;
}
React でのフック化パターン
実務でよく使う「1件のレコードを購読して自動更新する」フックの例です。
import { useEffect, useState } from "react";
function useIndexedDBRecord<T>(
fetcher: () => Promise<T | undefined>,
deps: unknown[]
) {
const [data, setData] = useState<T | undefined>(undefined);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetcher().then((result) => {
if (!cancelled) {
setData(result);
setLoading(false);
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { data, loading };
}
// 使い方
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading } = useIndexedDBRecord(
() => getUser(userId),
[userId]
);
if (loading) return <p>読み込み中...</p>;
if (!user) return <p>ユーザーが見つかりません</p>;
return <p>{user.name}</p>;
}
書き込み後に再フェッチしたい場合は、更新関数の中で key state を変えるなどして deps を発火させると簡潔に書けます。
つまずきやすいポイント
Safari のプライベートブラウジングで容量制限がある
Safari はプライベートモードで IndexedDB の容量が極端に小さくなります(環境によっては書き込み自体が失敗)。try/catch で保存失敗時のフォールバック(メモリ上のみで動作するなど)を用意しておくと安全です。
バージョンアップ時のマイグレーション
upgrade コールバックの第3引数で oldVersion が取れるので、段階的にマイグレーションを書けます。
upgrade(db, oldVersion) {
if (oldVersion < 1) {
db.createObjectStore("users", { keyPath: "id" });
}
if (oldVersion < 2) {
db.createObjectStore("posts", { keyPath: "id", autoIncrement: true });
}
}
versionを1上げるたびに if を積み増していくイメージです。既存ユーザーのブラウザには古いバージョンのDBが残っているため、必ず oldVersion 分岐でマイグレーションを書いてください。
同一オリジンの複数タブでバージョン競合が起きる
別タブで新しいバージョンの openDB が呼ばれると、古いタブの接続がブロックされます。blocked / blocking イベントをハンドリングして、ユーザーにリロードを促すのが定石です。
openDB<MyDB>("my-app-db", 2, {
upgrade(db, oldVersion) { /* ... */ },
blocked() {
console.warn("他のタブが古いDBバージョンを使用中です");
},
blocking() {
// このタブの接続を閉じて新しいタブの更新を許可する
location.reload();
},
});
まとめ
| やりたいこと | 使うAPI |
|---|---|
| DBを開く・スキーマ定義 | openDB(name, version, { upgrade }) |
| 1件追加(重複エラーあり) | db.add(store, value) |
| 1件追加・更新(upsert) | db.put(store, value) |
| 1件取得 | db.get(store, key) |
| 全件取得 | db.getAll(store) |
| インデックス検索 | db.getFromIndex / db.getAllFromIndex |
| 範囲検索 | IDBKeyRange.lowerBound / upperBound / bound |
| 複数操作の原子性担保 | db.transaction([...], "readwrite") + tx.done |
| 大量データの逐次処理 | openCursor() + cursor.continue() |
生の IndexedDB API は取り回しが悪いため、実務では idb ライブラリを使うのがほぼ一択です。この記事のパターンをベースに、キャッシュ層やオフライン対応機能を組んでみてください。