ReactでIndexedDBにデータを保存して読み出す方法
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、プライベートブラウジング、保存できる容量の上限、ブラウザがデータを消す動きは試していません。

IndexedDB の操作を、Promise を返す関数にまとめる
MDN の IndexedDB の使い方の説明にあるとおり、IndexedDB の API は、要求(IDBRequest)を出して、onsuccess や onerror で結果を受け取る形です。React のコンポーネントから直接書くと読みにくいので、データベースを扱う関数を1つのファイルにまとめ、どれも Promise を返すようにします。
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 にしておき、画面には「読み込み中…」を出します。
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 の中身がそろいます。
画面のコンポーネントは、入力欄と一覧を出すだけです。
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分進めています。途中で、アプリとは別にページの中からデータベースを開き、入っている値も書き出しました。
開いた直後:
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 で入れた場合と比べました。
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 しました。
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 の次のところを変えました。
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のページを開きました。
タブ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行目)。
タブ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 で古い接続を閉じておけば、新しいバージョンのタブを待たせずに済みます。





