IndexedDB 実践ガイド:コピペで使えるCRUD・トランザクション・検索パターン

IndexedDB はブラウザに搭載された NoSQL 型のデータベースです。localStorage と違って大容量のデータを扱え、インデックスによる検索やトランザクションもサポートしています。ただし生の API はコールバックベースで書きにくいため、この記事では実践的なコピペコードを中心にまとめます。

IndexedDB の基本を10秒で理解する

概念説明
Database1オリジンに複数作成可能。バージョン番号を持つ
Object StoreRDBでいうテーブル。key-value でレコードを保存
IndexObject 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.donePromise.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 ライブラリを使うのがほぼ一択です。この記事のパターンをベースに、キャッシュ層やオフライン対応機能を組んでみてください。