React+TypeScriptでPWAを個人開発した話|設計から公開までとハマったこと

React+TypeScriptでPWAを個人開発
この記事は約10分で読めます。
記事内に広告が含まれています。

前回の記事で紹介した、私が個人開発した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 には影響がありません。

種類ホスト名内容
CNAMEyameta(プロジェクト名).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アプリを作ってみたい方の参考になればうれしいです。

▶ やめた理由を使ってみる ▶ アプリの紹介記事

コメント

タイトルとURLをコピーしました