前回の記事で紹介した、私が個人開発したWebアプリ「やめた理由」の開発記です。
どんな技術で作り、どう進めて、どこでハマったのかをまとめます。これからWebアプリを作ってみたい方の参考になればうれしいです。
作ったもの
「やめた理由」は、一度検討してやめたことについて、やめた理由と再検討の条件を記録するアプリです。機能は次のとおりです。
- 記録の作成・編集・削除
- 状態(検討中・保留・見送り・採用)の変更と、判断の履歴
- 検索・タグ・状態での絞り込み
- ホーム画面への追加と、オフラインでの利用
- バックアップファイルの保存と復元
大きな特徴は、サーバーを使わず、ブラウザの中だけで動くことです。記録は利用者の端末に保存し、ログインもありません。個人的な記録を扱うアプリなので、運営者がデータを預からない設計にしました。
技術構成
| 役割 | 使ったもの |
|---|---|
| 画面 | React 19、TypeScript |
| 開発・ビルド | Vite |
| 画面遷移・データの読み書き | React Router(loader / action) |
| データの保存 | IndexedDB(ライブラリ idb を使用) |
| PWA(インストール・オフライン) | vite-plugin-pwa(Workbox) |
| テスト | Vitest、fake-indexeddb |
| 公開 | Cloudflare Pages |
選んだ理由は次のとおりです。
- React+TypeScript
画面の部品化がしやすく、型で入力データの間違いを早めに見つけられるからです。 - IndexedDB
ブラウザに組み込まれたデータベースで、サーバーなしでも構造化したデータを保存できます。localStorage と違って容量が大きく、トランザクションも使えます。 - React Router の loader / action
画面ごとに「データを読む処理」と「保存する処理」を分けて書けるので、画面のコードがすっきりしました。 - Cloudflare Pages
無料プランでも利用規約上、商用利用が制限されておらず、HTTPS やレスポンスヘッダーの設定もできるからです。
開発の進め方:仕様書とPhase
今回は、AIのコーディングアシスタント(Claude Code)と一緒に開発しました。そのときに効いたのが、最初に仕様書を書き、開発をPhase(段階)に分けることです。
仕様書は次の4つを用意しました。
- 設計書:アプリの目的、データの形、作らないもの
- Phase表:段階ごとに作るものと、完了の条件
- 画面仕様:画面ごとの表示や操作
- 開発ルール:「次のPhaseを先回りして作らない」「指示したときだけコミットする」など
Phaseは次のように分け、一つずつ実装と確認を繰り返しました。
| Phase | 内容 |
|---|---|
| 1 | 土台(Vite・React・画面遷移・IndexedDBの初期化・PWA) |
| 2 | 記録の作成・一覧・詳細・編集・削除 |
| 3 | 状態の変更と判断の履歴 |
| 4 | 検索・タグ・状態フィルタ |
| 5 | 入力チェック、オフライン、アプリ更新のお知らせ |
| 6 | 自動テストと公開準備 |
| 7 | バックアップと復元、公開、実機テスト |
| 8 | 紹介ページ・使い方・プライバシーポリシー |
AIに「Phase 2だけ作って」と頼むと、仕様書を読んで実装し、動作確認の結果と残課題を報告してくれます。私はその報告を確認して、設計の判断や次に進むかを決める役です。仕様書があるので、作業が別の日にまたがっても話がずれませんでした。
IndexedDBで工夫したこと
データが利用者の端末にしかないため、「データを壊さないこと」を最優先にしました。
1. データの形の変更は「追加だけ」にする
IndexedDBは、バージョン番号を上げると変更処理(マイグレーション)を実行できます。Phaseごとに保存先を足したので、バージョンは1から4まで上がりました。既に使っている人のデータを壊さないよう、過去の変更処理は書き換えず、末尾に追加するだけにしています。
// migrations[i] はバージョン i → i + 1 への移行処理
const migrations = [
() => {}, // v1: データベース作成のみ
(db) => { /* v2: 記録 */ },
async (db, tx) => { /* v3: 判断履歴(既存の記録に最初の履歴を補完) */ },
(db) => { /* v4: タグ */ },
]
for (let v = oldVersion; v < migrations.length; v++) {
await migrations[v](db, tx)
}2. 関連するデータは1つのトランザクションで書く
状態を変えるときは、「記録の状態を更新」と「履歴を追加」を同時に行います。ここで一度ハマりました。履歴の追加が例外で失敗したのに、状態の更新だけが保存されてしまったのです。
IndexedDBのトランザクションは、処理の途中でJavaScriptの例外が起きても自動では取り消されません。そこで、失敗したら明示的に abort() する共通の関数を用意しました。
async function writeAtomically(tx, write) {
try {
await Promise.all([write(), tx.done])
} catch (error) {
try { tx.abort() } catch { /* 既に終了済み */ }
throw error
}
}記録を削除するときも、履歴やタグの関連付けを同じトランザクションで消して、「親のいない履歴」が残らないようにしています。SQLのような外部キー制約がないので、整合性はアプリ側で守る必要があります。
3. 別のタブで新しい版が開かれたときに備える
同じアプリを2つのタブで開いているとき、片方だけが新しい版に更新されることがあります。IndexedDBは「古いタブが接続を持ったままだと、新しい版の変更処理が待たされる」仕組みなので、古いタブは通知(versionchange)を受けたら接続を閉じ、「別のタブでアプリが更新されました。再読み込みしてください」と表示するようにしました。
日本語の検索で工夫したこと
「前にも考えた?」と思ったときに、うろ覚えの言葉でも見つかることが大事なので、検索は表記ゆれを吸収するようにしました。データ量は個人の記録なので数百〜数千件を想定し、検索エンジンは使わずに全件を端末内で照合しています。
function normalizeForSearch(text: string): string {
return text
.normalize('NFKC') // 全角英数・半角カナをそろえる
.toLowerCase() // 大文字・小文字をそろえる
.replace(/[ァ-ヶ]/g, (c) =>
String.fromCharCode(c.charCodeAt(0) - 0x60), // カタカナをひらがなに
)
}これで「AWS」と「aws」、「クラウド」と「くらうど」が同じ言葉として見つかります。空白で区切ると、すべての言葉を含む記録だけに絞り込めます。一方で、「ぴーしー」と「PC」のような読み方の違いは辞書が必要になるので、今回は対応していません。
PWAと実機テストでハマったこと
PCのブラウザでは問題なく動いていても、実機で試すと想定外の動きがいくつも見つかりました。
1. iPhoneのホーム画面アプリで「更新する」が反応しない
このアプリは、新しい版を公開すると「新しいバージョンがあります」と表示し、利用者が「更新する」を押すと切り替わる仕組みです。入力中の内容が消えないよう、自動では再読み込みしません。
ところが、iPhoneのホーム画面に追加したアプリでは、このボタンが反応しませんでした。アプリが一度バックグラウンドに回ると、ライブラリが把握している「待機中の新しい版」と実際の状態がずれて、切り替えの指示が届かなかったと考えています。
対策として、ボタンを押した時点で登録情報を取り直し、待機中の Service Worker に直接「切り替えて」と送るようにしました。それでも数秒で切り替わらない場合は、「アプリを一度閉じてから開き直してください」と案内します。
const registration = await navigator.serviceWorker.getRegistration()
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
navigator.serviceWorker.addEventListener('controllerchange', () => location.reload())2. 「共有して保存」がAndroidとEdgeでエラーになる
バックアップファイル(JSON)を、スマートフォンの共有シートから保存できるようにしていました。navigator.canShare() で「共有できる」と判定されたのに、AndroidのChromeとPCのEdgeでは実際の共有が失敗しました。ブラウザは共有できるファイルの種類を制限していて、JSONは対象外のことが多いようです。共有に失敗したら、自動で通常のダウンロードに切り替えるようにしました。
3. iPhoneでは、Safariとホーム画面アプリのデータが別々
iPhoneでは、Safariで開いたときと、ホーム画面に追加したアプリで開いたときで、保存領域が別になります。Safariで記録してからホーム画面に追加すると、記録が空に見えます。Androidでは共通だったので、端末によって違います。アプリでは、「バックアップを保存して、ホーム画面のアプリで復元すると移せる」と案内しています。
4. ブラウザがデータを自動で消すことがある
iPhoneのSafariには、ホーム画面に追加していないサイトを使わずに7日間経つと、そのサイトのデータを消す仕組みがあります。他のブラウザも、容量が足りないときにはデータを消すことがあります。「数ヶ月後に読み返す」アプリとは相性が悪いので、次の3つで対策しました。
navigator.storage.persist()で、ブラウザに「消さないでほしい」と要求する- バックアップファイルの保存と復元を用意する(復元は1つのトランザクションで、失敗したら元のデータを残す)
- ホーム画面への追加と定期的なバックアップを、画面で控えめに案内する
公開:Cloudflare Pagesとブログのサブドメイン
公開先には Cloudflare Pages を選び、GitHub の main ブランチに push すると自動で公開されるようにしました。URL は、このブログのドメインのサブドメイン(yameta.yuuri-programming.com)です。
ブログはエックスサーバーで運用しているので、ネームサーバーは変更せず、エックスサーバーの DNS 設定に CNAME レコードを1件追加しただけです。ブログ側の設定には触っていないので、WordPress には影響がありません。
| 種類 | ホスト名 | 内容 |
|---|---|---|
| CNAME | yameta | (プロジェクト名).pages.dev |
公開で気を付けたことは次のとおりです。
- URLを直接開いても画面が出るようにする:Cloudflare Pages は、
404.htmlを置かなければどの URL にもindex.htmlを返してくれます。 - 古いアプリが残らないようにする:
index.htmlや Service Worker のファイルはブラウザにキャッシュさせず、ファイル名にハッシュが付く JS・CSS だけを1年間キャッシュさせています。 - 外部のスクリプトを読み込めないようにする:CSP(Content-Security-Policy)で、自分のサイトのファイルだけを読み込めるようにしました。
- pages.dev の URL を公開用の URL に転送する:IndexedDB のデータは URL(オリジン)ごとに別なので、利用者が別の URL で記録してしまわないようにしています。
テストと公開前のチェック
データを扱う処理は、Vitest で自動テストを書きました。IndexedDB は本来ブラウザの機能ですが、fake-indexeddb を使うと Node.js 上でもそのままテストできます。テストは最終的に71件になりました。
- 削除後に履歴やタグの関連付けが残らないか
- 書き込みが途中で失敗したとき、全体が取り消されるか
- 古いバージョンのデータを、データを残したまま新しい形に移せるか
- 不正なバックアップファイルを読み込んでも、データが変わらないか
テストが本当に問題を見つけられるかも、わざと処理を壊して、テストが失敗することで確かめました。
さらに、公開の前後に確認するスクリプトも用意しました。公開前には、ビルド結果に秘密情報らしき値やソースマップが混じっていないかを確認し、問題があれば公開を止めます。公開後には、HTTPS・キャッシュ・CSP・URL の直接アクセスなど、約35項目を自動で確認しています。
まとめ
- 「やめた理由」は、React・TypeScript・IndexedDB・PWA で作った、サーバーなしで動く Web アプリ
- 最初に仕様書を書き、Phase に分けて進めると、AI と一緒に開発しても話がずれない
- IndexedDB はマイグレーションとトランザクションを丁寧に扱うと、データを壊さずに済む
- PWA は実機で試すと、PC では分からない問題が見つかる(特に iPhone)
- Cloudflare Pages なら、既存ブログのサブドメインでも CNAME 1件で公開できる
個人開発は、小さく作って確かめることを繰り返すと、最後まで形にできます。Webアプリを作ってみたい方の参考になればうれしいです。
▶ やめた理由を使ってみる ▶ アプリの紹介記事

コメント