ReactでIndexedDBにデータを保存して読み出す方法

​​React​IndexedDB,Playwright,TypeScript,useEffect

IndexedDB を使うと、ブラウザの中にデータを保存して、ページを閉じたあとでも読み出せます。localStorage と違って文字列にしなくてよく、Date や Blob も、そのままの形で保存して取り出せます。

ここでは、メモを追加・削除できる小さなアプリを作り、IndexedDB の操作を Promise を返す関数にまとめて、React からは useEffect で読み込む形にします。保存できる値の種類、トランザクションの途中で待ってはいけないもの、データベースのバージョンを上げるときの動きも確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、ヘッドレスの Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright でページを操作しました。StrictMode を付けた開発ビルドでも結果は本番ビルドと同じで、どちらもコンソールにエラーや警告は出ませんでした。Firefox と Safari、プライベートブラウジング、保存できる容量の上限、ブラウザがデータを消す動きは試していません。

メモ欄に「牛乳を買う」と「本を返す」を入力して「追加」を押すと、それぞれ1行ずつ増える。上の帯の「再読み込み」を押しても2件のまま残る。「牛乳を買う」を削除してから再読み込みすると、「本を返す」の1件だけが残る
メモを2件追加し、再読み込み、削除、再読み込みをした様子。追加した行と件数、削除で変わった件数、再読み込みのあとに残っていた行と件数を、その瞬間だけピンクにしている。上の帯、カーソル、押した瞬間の輪は GIF 用に重ねたもの。再読み込みでは、実際にページを読み込み直している

IndexedDB の操作を、Promise を返す関数にまとめる

MDN の IndexedDB の使い方の説明にあるとおり、IndexedDB の API は、要求(IDBRequest)を出して、onsuccess や onerror で結果を受け取る形です。React のコンポーネントから直接書くと読みにくいので、データベースを扱う関数を1つのファイルにまとめ、どれも Promise を返すようにします。

ts
export type Note = { id: number; text: string; createdAt: Date };

const DB_NAME = 'memo-app';
const DB_VERSION = 1;
const STORE = 'notes';

// IDBRequest の結果を Promise で受け取る
function request<T>(req: IDBRequest<T>): Promise<T> {
  return new Promise((resolve, reject) => {
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

// トランザクションが最後まで書き込まれたら解決する
function done(tx: IDBTransaction): Promise<void> {
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error);
  });
}

let dbPromise: Promise<IDBDatabase> | null = null;

// データベースは1回だけ開いて、使い回す
export function openDB(): Promise<IDBDatabase> {
  dbPromise ??= new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);
    // 初めて開いたときと、DB_VERSION を上げたときだけ呼ばれる
    req.onupgradeneeded = () => {
      req.result.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
    };
    req.onsuccess = () => {
      const db = req.result;
      // 別のタブが新しいバージョンで開こうとしたら、閉じて譲る
      db.onversionchange = () => {
        db.close();
        dbPromise = null;
      };
      resolve(db);
    };
    req.onerror = () => {
      dbPromise = null;
      reject(req.error);
    };
  });
  return dbPromise;
}

export async function getAllNotes(): Promise<Note[]> {
  const db = await openDB();
  return request(db.transaction(STORE).objectStore(STORE).getAll());
}

export async function addNote(text: string): Promise<Note> {
  const db = await openDB();
  const tx = db.transaction(STORE, 'readwrite');
  const value = { text, createdAt: new Date() };
  // 追加の結果(付いた id)と、トランザクションの完了を両方待つ
  const [id] = await Promise.all([request(tx.objectStore(STORE).add(value)), done(tx)]);
  return { ...value, id: id as number };
}

export async function deleteNote(id: number): Promise<void> {
  const db = await openDB();
  const tx = db.transaction(STORE, 'readwrite');
  const finished = done(tx);
  tx.objectStore(STORE).delete(id);
  await finished;
}

データを入れる場所(オブジェクトストア)は、onupgradeneeded の中でしか作れません。同じページの、データベースのバージョンを作る・上げる節にあるとおり、このイベントは、データベースを初めて作ったときと、バージョンの番号を上げて開いたときに起きます。keyPath: 'id' と autoIncrement: true を指定したので、add で入れた値には 1、2、3…の番号が id として付き、add の結果としてその番号が返ります。

書き込みの関数は、要求の結果だけでなく、トランザクションの complete まで待ってから返します。同じページの、データを追加する節にある、トランザクションのイベントの説明によると、途中でエラーが起きたとき、そのエラーのイベントを処理しなければ、トランザクションはそれまでの変更ごと取り消され、abort イベントが届きます。エラーがなければ、すべての要求が終わったあとに complete イベントが届きます。保存が終わってから画面を変えたいので、complete まで待っています。addNote では、追加の結果(付いた id)とトランザクションの完了を、Promise.all でまとめて待っています。

openDB は、開いたデータベースの Promise を変数に入れて使い回します。毎回 indexedDB.open を呼ばずに済みます。StrictMode を付けた開発ビルドで数えたところ、エフェクトが2回動いて getAll は2回呼ばれましたが、indexedDB.open は1回だけでした。

IndexedDB を Promise で扱えるようにする小さなライブラリに idb もあります。ここでは何をしているかが見えるように、ライブラリを使わずに書きました(idb は試していません)。

useEffect で一覧を読み込み、追加と削除は保存のあとで state に反映する

React の側では、メモの一覧を state に持ちます。最初の表示のあと、useEffect の中で getAllNotes を呼んで読み込みます。読み込みが終わるまでは null にしておき、画面には「読み込み中…」を出します。

ts
import { useEffect, useState } from 'react';
import { addNote, deleteNote, getAllNotes, type Note } from './db';

export function useNotes() {
  const [notes, setNotes] = useState<Note[] | null>(null); // null は読み込み中
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let ignore = false;
    getAllNotes().then(
      (list) => { if (!ignore) setNotes(list); },
      (e) => { if (!ignore) setError(String(e)); },
    );
    return () => { ignore = true; };
  }, []);

  const add = async (text: string) => {
    try {
      const note = await addNote(text);
      setNotes((prev) => [...(prev ?? []), note]);
    } catch (e) {
      setError(String(e));
    }
  };

  const remove = async (id: number) => {
    try {
      await deleteNote(id);
      setNotes((prev) => prev && prev.filter((n) => n.id !== id));
    } catch (e) {
      setError(String(e));
    }
  };

  return { notes, error, add, remove };
}

ignore は、公式リファレンスにある、エフェクトでデータを取得する書き方と同じ考え方です。読み込みが終わる前にコンポーネントが外れたり、エフェクトが動き直したりしたときに、古い結果で state を書き換えないようにします。

追加と削除では、IndexedDB に書き込み終わってから、state の一覧も同じように変えます。保存のたびに全件を読み直すこともできますが、追加した値と id は addNote が返すので、読み直さなくても、画面の一覧と IndexedDB の中身がそろいます。

画面のコンポーネントは、入力欄と一覧を出すだけです。

tsx
import { useState } from 'react';
import { useNotes } from './useNotes';

export function Memo() {
  const { notes, error, add, remove } = useNotes();
  const [text, setText] = useState('');

  if (error) return <p role="alert">保存先を開けませんでした:{error}</p>;
  if (!notes) return <p>読み込み中…</p>;

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!text.trim()) return;
          add(text.trim());
          setText('');
        }}
      >
        <input aria-label="メモ" value={text} onChange={(e) => setText(e.target.value)} />
        <button>追加</button>
      </form>
      <p className="count">{notes.length}件</p>
      <ul>
        {notes.map((n) => (
          <li key={n.id}>
            <span>{n.text}</span>
            <time>{n.createdAt.toLocaleTimeString('ja-JP')}</time>
            <button onClick={() => remove(n.id)} aria-label={`「${n.text}」を削除`}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Playwright でメモを3件追加し、再読み込み、削除、もう一度再読み込みをしました。時計は 9:00 に合わせ、1件ごとに約1分進めています。途中で、アプリとは別にページの中からデータベースを開き、入っている値も書き出しました。

text
開いた直後:
0件
3件追加した:
3件
  - 牛乳を買う (9:00:00)
  - 本を返す (9:01:01)
  - 鍵を探す (9:02:02)
再読み込みした:
3件
  - 牛乳を買う (9:00:00)
  - 本を返す (9:01:01)
  - 鍵を探す (9:02:02)
再読み込みした(IndexedDB の中身):
  version=1 indexNames=[]
  id=1 text=牛乳を買う createdAt: [object Date] 2026-11-02T00:00:00.173Z
  id=2 text=本を返す createdAt: [object Date] 2026-11-02T00:01:01.245Z
  id=3 text=鍵を探す createdAt: [object Date] 2026-11-02T00:02:02.309Z
「本を返す」を削除した:
2件
  - 牛乳を買う (9:00:00)
  - 鍵を探す (9:02:02)
もう一度再読み込みした:
2件
  - 牛乳を買う (9:00:00)
  - 鍵を探す (9:02:02)
別のブラウザコンテキストで開いた:
0件
エラーと警告: なし

再読み込みしても3件が残り、createdAt は文字列ではなく Date として入っていました。削除したメモは、再読み込みのあとも戻りません。Playwright のブラウザコンテキストは、シークレットウィンドウのように保存先が分かれているので、別のコンテキストで開くと0件から始まりました。

注意

IndexedDB のデータは、何もしなければ「best-effort」という扱いで保存されます。MDN の、保存できる容量とデータが消される条件の説明によると、端末の空き容量が足りなくなると、ブラウザは、最近使われていないサイトのデータから消すことがあります。Safari では、サイト間トラッキングの防止が有効なとき、ブラウザを使った直近7日間に操作のなかったサイトのデータも消されます。navigator.storage.persist() で許可が得られれば、ユーザーが自分で消さない限り残ります。ほかに控えのない大事なデータは、IndexedDB だけに置かないようにします。この動きは、筆者の検証環境では試していません。

Date や Blob は、文字列にしなくても保存できる

IndexedDB に入れた値は、構造化複製(structured clone)という方法でコピーされてから保存されます。いくつかの種類の値を IndexedDB に入れて取り出し、localStorage に JSON.stringify で入れた場合と比べました。

text
Date       IndexedDB → [object Date] / localStorage(JSON) → [object String] "2026-11-02T00:00:00.000Z"
Map        IndexedDB → [object Map] get("a")=1 / localStorage(JSON) → [object Object] {}
Set        IndexedDB → [object Set] / localStorage(JSON) → [object Object] {}
Blob       IndexedDB → [object Blob] 中身="こんにちは" type=text/plain / localStorage(JSON) → [object Object] {}
Uint8Array IndexedDB → [object Uint8Array] / localStorage(JSON) → [object Object] {"0":1,"1":2,"2":3}
関数を含むオブジェクト: DataCloneError: Failed to execute 'put' on 'IDBObjectStore': () => {} could not be cloned.
クラスのインスタンス: 戻した値 [object Object] instanceof Point=false x=3 double=undefined

IndexedDB では、Date、Map、Set、Blob、Uint8Array が、入れたときと同じ種類のまま戻りました。JSON を通すと、Date は文字列になり、Map や Blob は中身のない {} になります。

一方、関数を含むオブジェクトは保存できず、DataCloneError になりました。クラスのインスタンスは、プロパティの値(x=3)は残りますが、ただのオブジェクトとして戻るので、instanceof は false になり、getter の double もありません。MDN の構造化複製の説明にも、関数は複製できないこと、プロトタイプチェーンは複製されないことが書かれています。state にクラスのインスタンスを持っている場合は、プロパティだけのオブジェクトにして保存し、読み出したあとで作り直します。

トランザクションの途中で、IndexedDB 以外のものを待たない

addNote では、トランザクションを始める前に、保存する値をすべてそろえています。トランザクションの途中で fetch などを待つと、そのトランザクションは使えなくなるからです。

readwrite のトランザクションで1回 put したあと、同じトランザクションの要求の結果、解決済みの Promise、setTimeout、fetch をそれぞれ await してから、もう一度 put しました。

text
IndexedDB の要求の結果を待つ → 続けて put できた
解決済みの Promise を待つ → 続けて put できた
setTimeout(0) を待つ → TransactionInactiveError: Failed to execute 'put' on 'IDBObjectStore': The transaction is not active.
fetch を待つ → TransactionInactiveError: Failed to execute 'put' on 'IDBObjectStore': The transaction has finished.

同じトランザクションの要求の結果や、すでに解決している Promise を待つだけなら、続けて書き込めました。setTimeout や fetch を待つと、TransactionInactiveError になります。fetch のほうは、待っている間にトランザクションが終わっていました。

MDN の IDBTransaction の説明によると、トランザクションに要求を出せるのは、トランザクションを作った処理(タスク)の中と、要求の success や error のイベントを処理している中だけです。それ以外のタスクで要求を出すと失敗します。また、新しい要求がなく、結果待ちの要求もなくなると、トランザクションは自動でコミットされます。

要求の結果を待った場合は、続きが success のイベントを処理している中で動くので、書き込めました。解決済みの Promise を待った場合も、続きが同じタスクの中で動くので、書き込めました。setTimeout の続きは別のタスクで動くので、失敗します。fetch を待っている間には、最初の put が終わって結果待ちの要求がなくなり、トランザクションは自動でコミットされていました。サーバーから取ってきた値を保存するときは、先に fetch を済ませてから、トランザクションを始めます。

保存する形を変えるときは、バージョンを上げる

あとからオブジェクトストアや索引(インデックス)を足すときは、DB_VERSION を上げて、onupgradeneeded の中で足します。event.oldVersion に、それまでのバージョン(初めて作るときは 0)が入っているので、足りない分だけを作ります。作った日時の索引を足したバージョン2では、db.ts の次のところを変えました。

ts
const DB_VERSION = 2;

// …(openDB の中)
    req.onupgradeneeded = (event) => {
      const db = req.result;
      if (event.oldVersion < 1) {
        db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
      }
      if (event.oldVersion < 2) {
        // バージョン2で、作った日時の索引を足す
        req.transaction!.objectStore(STORE).createIndex('byCreatedAt', 'createdAt');
      }
    };

ここで気をつけたいのは、古いバージョンのページを開いたままのタブがあるときです。バージョン1のページ(タブA)でメモを2件追加し、閉じないまま、別のタブ(タブB)でバージョン2のページを開きました。

text
タブB(バージョン2):
2件
  - 牛乳を買う (17:42:15)
  - 本を返す (17:42:15)
タブBを開いたあと(IndexedDB の中身):
  version=2 indexNames=[byCreatedAt]
  id=1 text=牛乳を買う createdAt: [object Date] 2026-11-02T08:42:15.097Z
  id=2 text=本を返す createdAt: [object Date] 2026-11-02T08:42:15.175Z
タブAでメモを追加しようとした:
保存先を開けませんでした:VersionError: The requested version (1) is less than the existing version (2).

タブBではバージョン2に上がり、索引が足され、前からあったメモも残りました。タブAは onversionchange でデータベースを閉じているので、タブBを止めずに済んでいます。ただし、タブAで次に保存しようとすると、バージョン1で開き直そうとして VersionError になります。タブAを古いコードのまま再読み込みしても、同じエラーが出ました。新しいコードが読み込まれるまで、古いタブでは保存できません。MDN の、別のタブでアプリを開いているときのバージョン変更の説明でも、VersionError を受け取ったときの対応を用意するように勧めています。

比べるために、onversionchange を書いていないバージョン1のページをタブAで開いたまま、同じことをしました。タブBでは、アプリのコードとは別に、indexedDB.open の要求に届く blocked イベントを数えています。届いたときには、古いバージョンと新しいバージョンをコンソールに書き出します(出力の1行目)。

text
  タブBのコンソール: blocked: oldVersion=1 newVersion=2
タブBを開いて3秒後:
読み込み中…
タブBに届いた blocked イベント: 1回
タブAを閉じたあとのタブB:
2件
  - 牛乳を買う (17:42:15)
  - 本を返す (17:42:15)

同じ MDN の説明にあるとおり、新しいバージョンで開くには、ほかのタブで開いている接続がすべて閉じられる必要があります。閉じられるまでは、開こうとした側に blocked イベントが届きます。MDN の例では、この onblocked で、ほかのタブを閉じるように案内を出しています。

検証では、タブBに blocked イベントが1回届き、タブAが開いている間、タブBは「読み込み中…」のまま止まりました。タブAを閉じると、ようやくバージョン2に上がって一覧が出ました。onversionchange で古い接続を閉じておけば、新しいバージョンのタブを待たせずに済みます。

​Posted by 86