Tips & Tricks (更新: 2026/6/2)

Claude CodeでIndexedDBを実装する入門ガイド

IndexedDBの設計、移行、同期、テストをClaude Codeで安全に進める実践ガイド。

Claude CodeでIndexedDBを実装する入門ガイド

IndexedDBは、ブラウザ内に構造化データを保存するための非同期データベースです。localStorageのように小さな文字列を置くだけではなく、オブジェクト、インデックス、トランザクション、バージョン管理を使えます。オフライン対応の下書き、検索できるキャッシュ、再送キューを作るなら、早い段階で候補に入れるべきAPIです。

ただし、APIは初学者にやさしくありません。openonupgradeneeded、object store、index、transaction、quota、別タブによるアップグレードブロックなど、失敗ポイントが多いからです。Claude Codeに「IndexedDBを実装して」とだけ頼むと、動くデモは出ても、スキーマ移行や同期衝突が抜けたまま公開されがちです。

この記事では、初心者がコピーして試せるTypeScript実装を使いながら、IndexedDBをlocalStorageとどう使い分けるか、バージョンアップで何を壊しやすいか、Claude Codeへどのように安全な指示を出すかを整理します。公式の確認先はMDNのIndexedDBガイドMDNのストレージクォータと削除基準web.devのStorage for the web、軽量ラッパーのidb README、より高機能なDexie.js docsです。関連する安全な依頼の考え方はClaude Code生産性TipsClaude Codeテスト戦略にもつながります。

IndexedDBとlocalStorageの使い分け

localStorageは、テーマ、最後に開いたタブ、短い設定値のような「少量の文字列」に向いています。同期APIなので呼び出しは簡単ですが、メインスレッドをブロックし、容量も小さく、検索や複数レコード更新には向きません。JSONを文字列化して大きな配列を保存し始めたら、ほぼ設計の匂いです。

IndexedDBは、ブラウザ内の小さなデータベースとして考えます。object storeはテーブルに近い入れ物、indexは検索用の道、transactionは複数の読み書きをまとめて成功または失敗にする仕組みです。MDNも、ネットワーク状態に関係なくリッチな検索を持つアプリを作れる点をIndexedDBの価値として説明しています。

保存したいものlocalStorageIndexedDB
テーマ、表示モード、短い設定向いている使えるが過剰
下書き、フォーム復元、長いJSON容量と同期処理が不安向いている
検索したい商品・記事キャッシュ全件JSONになりがちindexで検索できる
画像Blob、音声、添付メタ情報不向きBlobやメタ情報を保存できる
オフライン再送キュー順序や失敗管理が弱いtransactionで扱いやすい

具体的なユースケースは少なくとも4つあります。1つ目はCMSやブログエディタの下書き保存です。通信が切れても入力中の本文を消さないため、updatedAtsyncStatusで検索できるようにします。2つ目はECやSaaSの検索結果キャッシュです。商品一覧、プラン、ヘルプ記事をcategoryupdatedAtのindexで引けます。3つ目はPWAのオフライン操作キューです。問い合わせ送信、コメント投稿、在庫調整をキューに積み、オンライン復帰時に再送します。4つ目はClaude Codeで生成した中間結果の保存です。大きな解析結果を毎回APIから取り直すより、ブラウザ側で期限付きキャッシュにした方が体験が安定します。

スキーマ、バージョン、indexを先に設計する

IndexedDBで最初に決めるのは「何を保存するか」ではなく「どのキーで取り出すか」です。idで1件取得するだけなら簡単ですが、実アプリでは「未同期の下書きだけ」「古いキャッシュから」「失敗回数が少ないジョブから」のように検索します。この検索軸を後から足すにはDBバージョンを上げ、アップグレード処理でindexを追加する必要があります。

失敗例は、初期版でnotes storeだけ作り、後でsyncStatus検索が必要になったのに、全件取得してJavaScriptでfilterする実装です。データが50件なら気づきませんが、5000件になると起動直後のUIが重くなります。もう1つの失敗例は、version: 1.1のような小数を渡すことです。MDNはIndexedDBのバージョンが整数で扱われる点を警告しています。移行番号は1, 2, 3のように増やします。

npm i idb

次のコードはViteやNext.jsのクライアント側モジュールに置ける最小構成です。idbはIndexedDBをPromiseで扱いやすくする軽量ラッパーです。生APIを学ぶ価値はありますが、実務ではラッパーを使った方がClaude Codeの生成コードもレビューしやすくなります。複雑なクエリ、live query、より高い抽象度が必要ならDexieも候補ですが、この記事では小さく始めやすいidbに寄せます。

import {
  openDB,
  type DBSchema,
  type IDBPDatabase,
} from "idb";

type SyncStatus = "clean" | "dirty" | "syncing" | "failed";

export interface DraftNote {
  id: string;
  title: string;
  body: string;
  updatedAt: number;
  baseVersion: number;
  syncStatus: SyncStatus;
}

export interface SyncJob {
  id: string;
  noteId: string;
  operation: "upsert-note" | "delete-note";
  payload: unknown;
  createdAt: number;
  attempts: number;
  lastError?: string;
}

interface AppDB extends DBSchema {
  notes: {
    key: string;
    value: DraftNote;
    indexes: {
      "by-updated-at": number;
      "by-sync-status": SyncStatus;
    };
  };
  syncQueue: {
    key: string;
    value: SyncJob;
    indexes: {
      "by-created-at": number;
      "by-attempts": number;
    };
  };
}

const DB_NAME = "claude-code-indexeddb-demo";
const DB_VERSION = 2;

let dbPromise: Promise<IDBPDatabase<AppDB>> | undefined;

export function getDb() {
  dbPromise ??= openDB<AppDB>(DB_NAME, DB_VERSION, {
    upgrade(db, oldVersion, _newVersion, tx) {
      if (oldVersion < 1) {
        const notes = db.createObjectStore("notes", {
          keyPath: "id",
        });
        notes.createIndex("by-updated-at", "updatedAt");

        const queue = db.createObjectStore("syncQueue", {
          keyPath: "id",
        });
        queue.createIndex("by-created-at", "createdAt");
        queue.createIndex("by-attempts", "attempts");
      }

      if (oldVersion < 2) {
        const notes = tx.objectStore("notes");
        if (!notes.indexNames.contains("by-sync-status")) {
          notes.createIndex("by-sync-status", "syncStatus");
        }
      }
    },
    blocked() {
      console.warn("Close other tabs to finish the DB upgrade.");
    },
    blocking() {
      dbPromise?.then((db) => db.close()).catch(() => {});
    },
  });

  return dbPromise;
}

ポイントは、store作成とindex追加をupgradeに閉じ込めることです。別タブで古いDB接続が開いたままだと、新しいバージョンのオープンが止まることがあります。blockedではユーザーに別タブを閉じる案内を出し、blockingでは自分のタブが将来の移行を邪魔しないように接続を閉じます。

transactionで下書きと同期キューを同時に保存する

IndexedDBのtransactionは、途中で失敗したときに中途半端な状態を避けるための仕組みです。たとえば下書きを保存したのに同期キューへの追加だけ失敗すると、画面上は保存済みに見えるのにサーバーへ送られません。下書きとキューは1つのreadwrite transactionでまとめます。

const createId = () =>
  globalThis.crypto?.randomUUID?.() ??
  Math.random().toString(36).slice(2);

export async function saveDraft(input: {
  id?: string;
  title: string;
  body: string;
  baseVersion?: number;
}) {
  const db = await getDb();
  const now = Date.now();
  const noteId = input.id ?? createId();

  const note: DraftNote = {
    id: noteId,
    title: input.title,
    body: input.body,
    updatedAt: now,
    baseVersion: input.baseVersion ?? 0,
    syncStatus: "dirty",
  };

  const job: SyncJob = {
    id: createId(),
    noteId,
    operation: "upsert-note",
    payload: note,
    createdAt: now,
    attempts: 0,
  };

  const tx = db.transaction(["notes", "syncQueue"], "readwrite");
  await tx.objectStore("notes").put(note);
  await tx.objectStore("syncQueue").put(job);
  await tx.done;

  return note;
}

export async function getDirtyNotes() {
  const db = await getDb();
  return db.getAllFromIndex("notes", "by-sync-status", "dirty");
}

落とし穴は、transaction中にfetchなどIndexedDB以外の非同期処理を挟むことです。idbのREADMEも、transactionが自動的に閉じるタイミングについて注意しています。サーバー送信はtransactionの外で行い、成功後に別transactionで状態を更新する方が安全です。

quota、eviction、エラー処理を必ず入れる

IndexedDBは「無限の保存場所」ではありません。ブラウザや端末の空き容量、プライベートブラウズ、ユーザー操作、ストレージ圧迫によって、書き込みが失敗したり、ブラウザがデータを削除したりする可能性があります。MDNとweb.devは、QuotaExceededErrorやbest effort storage、persistent storageの考え方を確認する入口になります。

初心者がやりがちな失敗は、await saveDraft()を呼ぶだけでcatchしないことです。容量超過が起きるとUIは保存できたように見え、リロード後に消えます。もう1つは、IndexedDBに保存したデータをサーバーバックアップなしの唯一の原本にすることです。オフライン対応は便利ですが、収益や契約に関わるデータは最終的にサーバーへ同期する前提にします。

export async function estimateStorage() {
  if (!navigator.storage?.estimate) {
    return { usage: undefined, quota: undefined };
  }

  const { usage, quota } = await navigator.storage.estimate();
  return { usage, quota };
}

export async function requestPersistentStorage() {
  if (!navigator.storage?.persist) {
    return false;
  }

  return navigator.storage.persist();
}

export function isQuotaError(error: unknown) {
  return (
    error instanceof DOMException &&
    error.name === "QuotaExceededError"
  );
}

export async function saveDraftWithErrorMessage(
  input: Parameters<typeof saveDraft>[0],
) {
  try {
    return await saveDraft(input);
  } catch (error) {
    if (isQuotaError(error)) {
      throw new Error(
        "ブラウザ保存領域が不足しています。不要な下書きを削除してください。",
      );
    }

    throw error;
  }
}

persist()は保存を絶対に保証する魔法ではありません。ユーザーやブラウザの判断が入ります。したがって、アプリ側では保存容量の見積もり、古いキャッシュの削除、サーバー同期、エラー表示を組み合わせます。Claude Codeへは「quota失敗時のUI文言とテストも作る」と明示してください。

オフラインキューと同期衝突の実装

オフラインファーストでは、キューに積むだけでは不十分です。送信順序、リトライ回数、同じ操作の重複、サーバー側の更新との衝突を決める必要があります。たとえばユーザーAがオフラインで下書きを編集し、同じノートを別端末でユーザーAが更新した場合、後からオンライン復帰したブラウザが古い内容で上書きしてしまうことがあります。

type SendJob = (job: SyncJob) => Promise<void>;

export async function flushSyncQueue(sendJob: SendJob) {
  if (!navigator.onLine) return;

  const db = await getDb();
  const jobs = await db.getAllFromIndex(
    "syncQueue",
    "by-created-at",
  );

  for (const job of jobs) {
    try {
      await sendJob(job);
      await db.delete("syncQueue", job.id);
    } catch (error) {
      const message =
        error instanceof Error ? error.message : "Unknown sync error";

      await db.put("syncQueue", {
        ...job,
        attempts: job.attempts + 1,
        lastError: message,
      });
    }
  }
}

window.addEventListener("online", () => {
  void flushSyncQueue(async (job) => {
    const response = await fetch("/api/sync", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(job),
    });

    if (!response.ok) {
      throw new Error(`Sync failed: ${response.status}`);
    }
  });
});

衝突対策はアプリの性質で変わります。単純なメモなら「サーバー版とローカル版を並べて選ばせる」で十分なことがあります。請求、予約、在庫のようなデータなら、クライアントだけで解決せず、サーバー側でバージョン番号やETagを見て拒否する方が安全です。

export function detectConflict(input: {
  local: DraftNote;
  remoteVersion: number;
}) {
  const { local, remoteVersion } = input;

  return (
    local.syncStatus === "dirty" &&
    remoteVersion > local.baseVersion
  );
}

テストとモックの現実的な範囲

単体テストではfake-indexeddbを使うと、Node上でIndexedDBに近いAPIを動かせます。これは保存関数、index検索、移行処理の回帰テストに便利です。ただし、quota、eviction、プライベートブラウズ、複数タブのblockedは実ブラウザで見ないと判断できません。単体テスト、Playwrightのブラウザテスト、手動確認を分けて考えます。

npm i -D vitest fake-indexeddb
import "fake-indexeddb/auto";
import { beforeEach, expect, test } from "vitest";
import { deleteDB } from "idb";
import {
  getDirtyNotes,
  saveDraft,
} from "./local-db";

beforeEach(async () => {
  await deleteDB("claude-code-indexeddb-demo");
});

test("saves a draft and finds it by sync status", async () => {
  await saveDraft({
    title: "IndexedDB note",
    body: "Saved while offline",
  });

  const dirtyNotes = await getDirtyNotes();

  expect(dirtyNotes).toHaveLength(1);
  expect(dirtyNotes[0]?.syncStatus).toBe("dirty");
});

検証用のチェックリストはシンプルで構いません。初回起動でDBが作られるか、バージョン2へ移行してindexが増えるか、別タブを開いたまま更新したときに案内が出るか、オフラインで保存してオンライン復帰時に再送されるか、容量不足時にユーザーが理解できるエラーになるかを見ます。収益導線のあるページなら、CTAや計測イベントがIndexedDB対応で壊れていないことも確認します。

Claude Codeへの安全な依頼文

Claude Codeに任せるときは、実装だけではなく境界条件を書きます。特にIndexedDBは「一見動くが運用で壊れる」パターンが多いので、対象ファイル、DB名、version、store、index、移行、テスト、触ってはいけない範囲を明示します。

claude <<'PROMPT'
Scope:
- Edit only src/lib/local-db.ts and src/lib/local-db.test.ts.
- Do not change API routes, pricing copy, analytics, or CTA links.

Build:
- Use IndexedDB through the idb package.
- Database name: claude-code-indexeddb-demo.
- Version 2 must create notes and syncQueue stores.
- Add indexes for updatedAt, syncStatus, createdAt, and attempts.

Reliability:
- Use readwrite transactions for note + queue writes.
- Do not await fetch inside an active IndexedDB transaction.
- Handle QuotaExceededError with a user-facing message.
- Explain blocked upgrades from another open tab.

Testing:
- Use vitest and fake-indexeddb.
- Test draft save, dirty-note lookup, and queue insertion.
- Return findings, patch summary, commands, and residual risk.
PROMPT

この依頼文の狙いは、Claude Codeの作業を「便利な保存機能」ではなく「レビュー可能なローカルデータ層」にすることです。マネタイズしているブログやSaaSでは、オフライン下書きやキャッシュ改善がCTAクリック、問い合わせ、購入導線を邪魔しないことも品質の一部です。導入設計を短時間で棚卸ししたい場合はClaude Code研修・導入相談へ、自分で進めたい場合は無料チートシート実践テンプレートを合わせて使うと、依頼文と検証項目を標準化できます。

この記事で紹介した内容を実際に試した結果

Masaが小さなメモアプリでこの構成を試したとき、最も効いたのは最初にsyncStatusupdatedAtのindexを決めたことでした。初期版では全件取得して未同期だけfilterしていましたが、テストデータを増やすと起動直後の処理が重くなりました。idbに寄せ、下書き保存とキュー追加を1つのtransactionにした後は、失敗時の状態を説明しやすくなりました。一方で、fake-indexeddbだけではquotaや別タブブロックは再現できませんでした。最終確認ではChromeのDevToolsでストレージを削除し、ネットワークをOfflineにして、保存、再読込、オンライン復帰、再送までを手で確認しました。

#Claude Code #IndexedDB #ローカルストレージ #オフライン #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。