はじめに
家庭の買い物では、毎回ゼロからメモを書くよりも、普段買う商品から必要なものだけを選べる方が便利です。
また、家族の誰かが商品を追加したり、店頭で購入済みにしたりした結果を、別の端末にも反映したいという要件がありました。
最初はスマートフォン版Google Keepのチェックリストを使っていましたが、自分たちの使い方では次の点が課題として残りました。
- 1つの長い商品リスト内を、商品名で素早く絞り込めない
- スクロール中のタッチ操作で商品が意図せず移動し、肉、野菜、日用品など、近い商品をまとめた配置が崩れることがある
- Keepで全商品を消さずに使い回すと、チェック済みが「今は買わない商品」と「今回購入した商品」の両方を表す。チェックの有無だけでは、この二つを状態として区別できない
そこで、家族で共有して繰り返し使える買い物リストPWA ToggleList を作りました。私も妻もAndroidを使っているため、クロスプラットフォーム対応を重視したわけではありません。Android専用アプリを一から作るより形にしやすいと考え、PWAを選びました。
この記事では、アプリの作り方を最初から解説するのではなく、要件をどう整理して実装し、使い始めてから何を改善したかを紹介します。主に扱うのは次の点です。
- 商品マスタを3状態で管理し、誤操作しにくい並べ替え方法にする
- IndexedDBのキャッシュと楽観的更新で通信待ちを感じにくくする
- 日本語の表記揺れを吸収し、読みや別名でも検索できるようにする
- Cloudflare AccessとGoogle OAuthで利用者を家族に限定する
- 最終購入日やカテゴリの折りたたみを、実際に使いながら追加する
- 更新通知、再ログイン、戻る操作など、インストール後のPWAの挙動を整える
既存サービスでは何が足りなかったか
記事公開前の2026年8月時点で、公式情報を確認したうえで、ストックシェアはAndroid実機、Yarutto!は実際のブラウザ画面でも主要操作を試しました。確認できなかった機能については、存在しないと断定せず「調査時点では確認できなかった」としています。
Google Keep
スマートフォン版Google Keepには、Keep全体からノートやリストを探す検索はありますが、開いている1つのチェックリストだけをその場で絞り込む検索欄はありません。また、スクロール中に項目が意図せず移動し、カテゴリ分けが崩れることもありました。
ToggleListでは通常画面で商品を移動できないようにし、専用の並べ替え画面で▲▼を押した場合だけ順序を変更できるようにしました。
買い物リスト - シンプルなお買い物メモのアプリ
Google Playで「買い物リスト」を検索すると上位に表示される、Komorebi Inc.の買い物リスト - シンプルなお買い物メモのアプリもAndroid実機で試しました。
1画面で商品の追加、チェック、並べ替え、色分け、削除ができ、ホーム画面ウィジェットにも対応しています。行全体ではなく右端の専用ハンドルで並べ替えるため、Keepよりスクロール中の誤移動は起きにくそうです。
チェック済み項目を残し、必要になったらチェックを外すことで、定番品マスターのように使うこともできます。ただし、商品検索、名前付きカテゴリ、商品ごとの最終購入日の表示はありません。共有もリストをテキストとして送る機能で、家族とのリアルタイム共同編集ではありません。一人で使うシンプルな買い物メモとしては十分ですが、今回必要だった検索と家族共有には対応しません。
ストックシェア
ストックシェアは、今回の要件に最も近いアプリでした。Android実機で、常備品から買うものリストへの追加、購入完了後に常備品へ戻る流れ、カテゴリ、検索、招待コードによる家族共有を確認しました。無料プランは商品100個、カテゴリ5個、リスト1個までです。
並べ替えも試しました。通常のスクロールでは移動せず、長押し状態になってから上下へ動かすと並べ替わります。Keepより長押し判定が明確で、実用上は誤移動しにくそうでした。ToggleListのように並べ替え画面を完全に分離してはいませんが、この違いだけで自作が必須になるとは感じませんでした。
率直に言えば、開発前にストックシェアを知っていたら、おそらくまずこれを使っていたと思います。スマートフォンだけで使い、無料版の上限に収まり、固定商品マスタの見せ方や最終購入日の確認に強いこだわりがなければ、十分な選択肢です。
一方、ストックシェアは「家に常備するものが切れたら補充する」という在庫補充型です。ToggleListは、肉・野菜・季節品・たまに買うものを含む商品全体から、その都度必要なものを選ぶ使い方を中心にしています。任意の読み・家庭内の別名と、商品ごとの最終購入日の表示はToggleListに残った違いです。
Yarutto!
Yarutto!は、アカウントもインストールも不要で使えるブラウザベースの共有チェックリストです。非公開リストを実際に作り、二つのブラウザ画面で、片方の変更がもう片方へページ更新なしで反映されることを確認しました。カテゴリ、担当者、テンプレート複製があります。
ただし、買い物専用ではなく汎用チェックリストです。リスト内検索、読み・別名、商品ごとの最終購入日の表示はありません。公式FAQでもオンライン専用とされています。URLだけで共有できる手軽さは優れていますが、商品数の多い固定リストを繰り返し使う今回の用途とは異なりました。
比較して残った要件
実際に確認したアプリを、今回の使い方に関係する主要項目だけで比較すると次のようになりました。
| 観点 | Google Keep | シンプル買い物リスト | ストックシェア | Yarutto! | ToggleList |
|---|---|---|---|---|---|
| 主な用途 | メモ・簡易チェックリスト | 一人用の買い物メモ | 常備品の在庫補充 | 汎用共有チェックリスト | 家庭内の商品マスタ |
| 家族共有 | あり | テキスト送信のみ | あり | URL共有 | Googleアカウントを限定 |
| リスト内検索 | なし | なし | あり | なし | あり |
| 日本語の読み・別名 | なし | なし | 読みを自動登録 | なし | 任意に登録 |
| 誤並べ替え対策 | 行のドラッグが有効 | 専用ハンドル | 長押し判定 | 調査時点では確認できず | 専用画面のみ |
| 商品ごとの最終購入日 | なし | なし | 調査時点では確認できず | なし | あり |
比較で重視した要件は次の組み合わせです。
| 要件 | 内容 |
|---|---|
| 繰り返し使える商品マスタ | 全商品を消さず、今回必要なものだけを選ぶ |
| 3状態 | 対象外/買い物対象/購入済みを明示的に切り替える |
| カテゴリ内の配置 | 肉、野菜、日用品など、近い商品を同じ場所にまとめた配置を維持する |
| 誤操作防止 | 通常画面では移動できず、専用の並べ替え画面で▲▼を押した場合だけ移動する |
| 日本語検索 | ひらがな、カタカナ、半角全角を吸収する |
| 読み・別名 | 漢字の商品を読みや家庭内の呼び方でも探す |
| 家族共有 | 複数端末で同じ買い物状態を更新する |
| 買い物完了 | 購入済みだけを一括で対象外へ戻し、履歴を残す |
個々の機能を持つ既存アプリはあります。ToggleListを作った理由は、アプリが一つも存在しなかったからではなく、自分たちが重視する機能の組み合わせを、分かりやすい操作で満たすものを確認できなかったためです。
作ったもの
ToggleListでは、登録した商品を次の3状態で管理します。
対象外 → 買い物対象 → 購入済み
↑ │
└─── 買い物完了 ────┘
買い物が終わったら「買い物完了」を実行し、購入済みの商品をまとめて対象外へ戻します。買わなかった商品は買い物対象のまま残るため、次回へ持ち越すか個別に対象外へ戻せます。
主な機能は次のとおりです。
- カテゴリ別の商品一覧
- 買い物対象への追加と取り消し
- 購入済みへの切り替え
- 買い物完了による一括リセット
- 商品の追加、編集、削除、並べ替え
- カテゴリ単位/一括での折りたたみ
- 商品ごとの最終購入日の表示
- 直近の変更履歴
- 日本語の表記揺れを考慮した検索
- JSON形式でのデータ書き出し
- 複数端末間の同期
使ってから欲しくなった最終購入日の表示
商品ごとの最終購入日は、最初から決めていた機能ではありません。実際に使っていると、店頭で「これは最近買った気がするが、いつだったか」が気になる場面がありました。そこで、最後に買った日を商品ごとに確認できるようにしました。
これは汎用的な高機能化というより、自分たちで使ったから見つかった要件です。自分専用アプリは、使いながら生じた小さな不満を、そのまま次の修正へつなげられる点に価値があると感じました。
商品が増えてから欲しくなったカテゴリの折りたたみ
商品マスタが増えると、検索しないときの一覧が長くなりました。そこで、カテゴリごとに開閉できるようにし、「すべて折りたたむ」「すべて展開」も追加しました。
ただし、検索中までカテゴリの開閉状態を引き継ぐと、該当商品が折りたたまれたままになり、「検索結果がない」ように見えてしまいます。そのため、検索中と並べ替え中は該当カテゴリを自動的に展開します。機能を足すだけでなく、既存の検索や並べ替えと組み合わせたときに迷わない挙動にする必要がありました。
家族の操作がわかる変更履歴
変更履歴では、商品を買い物対象や購入済みにした操作と、買い物完了時の商品を確認できます。長期間保存する監査履歴ではなく、家族の操作が食い違ったときに、誰がいつ変更したかを確かめるための簡単な手掛かりです。
技術構成
構成は次のとおりです。
| 役割 | 技術 |
|---|---|
| フロントエンド | React 19 / TypeScript / Vite |
| PWA | vite-plugin-pwa |
| 端末キャッシュ | Dexie / IndexedDB |
| API | Cloudflare Workers / Hono |
| 共有データベース | Cloudflare D1 |
| 認証 | Cloudflare Access / Google OAuth |
| デプロイ | GitHub連携のCloudflare Workers Builds |
ブラウザからのAPIリクエストはWorkerで処理し、共有データをD1へ保存します。
IndexedDBは、ChromeやEdgeなどのブラウザに標準で備わっている端末内データベースです。サーバー上のD1とは違い、そのスマートフォンやPCのブラウザ内にデータを保存します。localStorageより多くの構造化データを扱え、非同期で読み書きできるため、前回取得した商品一覧のキャッシュに使いました。DexieはIndexedDBをTypeScriptから扱いやすくするためのライブラリです。
PWA
├─ IndexedDB:前回取得した表示用キャッシュ
└─ Cloudflare Worker API
└─ D1:家族で共有するデータの保存先
IndexedDBは各端末に前回の状態を置き、通信完了前にすぐ表示するために使います。家族間で共有するデータはD1へ保存します。
商品の状態はWorker APIを通してD1へ保存し、家族の端末から共有します。他端末の変更は次のタイミングで取得します。
- 画面表示中は約30秒ごと
- PWAがバックグラウンドから復帰したとき
- ウィンドウが再びアクティブになったとき
起動直後でも操作を待たせない
通常は通信待ちを意識することはほとんどありませんでしたが、何度か、PWAを開いて買い物を始めた直後の最初の購入済み操作だけ、画面へ反映されるまで2〜3秒待つことがありました。その後の操作はすぐに反映されていました。
同じ状況で繰り返し起きたため、起動直後の操作と同期処理が重なっている可能性を考え、修正することにしました。
当時のログがないため原因は断定できません。有力だと考えているのは、閉じたつもりのPWAがバックグラウンドで保持され、前回の一覧を表示したまま復帰したという可能性です。復帰時にはfocusとvisibilitychangeを契機にD1から一覧を再取得していたため、その同期中に購入済み操作が重なったのかもしれません。
修正前は、購入済みを押すと、まず更新APIの完了を待ち、その後に一覧全体を取得し直していました。画面の状態を変えるのは、最後の一覧再取得が終わってからでした。
await updateItem(item.id, { status: 'purchased' })
await refreshSnapshot()
つまり、画面へ反映するまでに更新と再取得の通信完了を待つ実装でした。これだけで2〜3秒の原因を証明することはできませんが、復帰時の同期と更新操作が重なった場合に、操作結果の表示が遅れるコードだったことは確認できます。
そこで、よく使う更新操作には楽観的更新を導入しました。
- 画面を先に変更する
- 裏側でWorker APIへ保存する
- 成功したらそのまま確定する
- 失敗したら操作前の状態へ戻す
実装では、更新処理に「画面更新」「ロールバック」「APIリクエスト」を渡しています。
async function runOptimisticMutation(
affectedIds: string[],
applyOptimisticUpdate: () => void,
rollback: () => void,
request: () => Promise<void>,
) {
applyOptimisticUpdate()
try {
await request()
} catch (error) {
rollback()
setSyncError(
'変更を保存できませんでした。通信状態を確認して、もう一度お試しください。',
)
}
}
実際のコードでは、さらに次の制御を加えています。
- 同じ商品への通信中の二重操作を防ぐ
- 通信中に開始した古い再取得結果が、最新の画面状態を上書きしないようにする
- すべての変更通信が完了した後、D1からスナップショットを再取得する
さらに、その後の修正で、復帰時にfocusとvisibilitychangeが続けて発生しても、同時に複数の再取得を始めないようにしました。
これにより、画面は即座に反応しつつ、最終的にはD1の状態と整合できます。修正後は、起動直後の購入済み操作で待たされる挙動も起きていません。
同じ修正では、次回以降の起動時に一覧の取得を待たなくてよいよう、IndexedDBのキャッシュも追加しました。これは購入済み操作の遅れとは別の改善です。
- IndexedDBに前回データがあれば、すぐ表示する
- 裏側でWorker APIから最新版を取得する
- 取得成功後、画面とIndexedDBを更新する
- 通信失敗時は前回データを表示したままエラーを知らせる
起動処理の要点は次のようになります。
const cached = await loadCachedSnapshot()
if (cached) {
applySnapshot(cached.snapshot)
setShowingCachedSnapshot(true)
}
await refreshSnapshot(true, true)
キャッシュの保存にはDexieを使っています。
await db.transaction(
'rw',
db.lists,
db.categories,
db.items,
db.cacheMetadata,
async () => {
await db.categories.where('listId').equals(snapshot.list.id).delete()
await db.items.where('listId').equals(snapshot.list.id).delete()
await db.lists.put(snapshot.list)
await db.categories.bulkPut(snapshot.categories)
await db.items.bulkPut(snapshot.items)
await db.cacheMetadata.put({ key: 'server-snapshot', cachedAt })
},
)
一覧、カテゴリ、商品、取得日時を1トランザクションで更新することで、中途半端なキャッシュが残りにくくしています。
日本語検索の表記揺れを吸収する
商品検索では、ひらがなとカタカナ、全角と半角が混在します。
そこで、検索語と商品名の両方を正規化してから比較します。主に次の処理を行います。
- Unicode NFKC正規化
- カタカナをひらがなへ統一
- 英字を小文字へ統一
- 空白を除去
概念的には次のような処理です。
function normalizeSearchText(value: string): string {
return value
.normalize('NFKC')
.replace(/[ァ-ヶ]/g, (char) =>
String.fromCharCode(char.charCodeAt(0) - 0x60),
)
.toLocaleLowerCase('ja')
.replace(/\s+/g, '')
}
これにより、たとえば次の入力を近いものとして扱えます。
ヨーグルト
よーぐると
ヨーグルト
漢字の商品には読み・別名を持たせる
文字種の正規化だけでは、「牛乳」を「ぎゅうにゅう」で検索することはできません。
JavaScriptだけで任意の漢字を正確に読みに変換するのは難しく、商品名では固有の読み方もあります。そこで、自動変換ではなく、商品ごとに検索用の読み・別名を登録できるようにしました。
商品名: 牛乳
検索用の読み・別名: ぎゅうにゅう ミルク
D1にはsearch_keywordsを保存し、検索時は商品名と検索用キーワードを同じ正規化処理へ通します。
この方式には手入力が必要ですが、次の利点があります。
- 読み間違いがない
- 略称や家庭内の呼び方も登録できる
- 外部の形態素解析APIや辞書へ依存しない
- 商品数が多くない用途では管理しやすい
Cloudflare Accessで家族だけに限定する
買い物リストには個人的な生活情報が含まれるため、URLを知っているだけで誰でも開ける状態にはしたくありませんでした。
そこで、Workerの前段にCloudflare Accessを置き、Google OAuthで本人確認を行っています。
利用者
↓
Google OAuthによる本人確認
↓
Cloudflare Accessのメール完全一致ポリシー
↓
Worker / D1
重要なのは、Google OAuthとCloudflare Accessの役割が異なることです。
- Google OAuth: Googleアカウントの本人確認
- Cloudflare Access: 認証済みユーザーをアプリへ通すか判断
AccessのAllowポリシーには、利用を許可するメールアドレスだけを完全一致で登録しています。また、本番URLだけでなく、GitHub連携ビルドで作られるプレビューURLも保護します。
OAuthクライアントには、Cloudflare Accessの生成元とコールバックURLだけを登録しています。Client SecretやCloudflare API Tokenはリポジトリへ保存しません。D1のUUIDも秘密鍵ではありませんが、利用環境の識別子として扱っています。
家族利用では無料枠に収まっている
2026年8月時点では、ToggleListの運用費は発生していません。Cloudflare WorkersのFreeプランには1日10万リクエストが含まれ、D1にも1日500万行の読み取り、10万行の書き込み、合計5GBの無料枠があります。家族二人で使う規模では、現在の使用量はこの範囲に十分収まっています。Cloudflare AccessにもFreeプランがあります。
Google側で使っているのは、Cloudflare Accessから転送された利用者をGoogleアカウントで認証するためのOAuthクライアントです。Googleの有料APIを呼び出しているわけではなく、現在この認証による料金は発生していません。
Google OAuthには、WorkersやD1のような「1日何リクエストまで無料」という料金表はありません。公式情報にあるのは課金上限ではなく、不正利用を防ぐための認可レート制限です。その上限は一律の件数ではなく、アプリの利用実績、開発者の信頼性、要求する権限のリスクなどによって決まります。ToggleListは家族二人の本人確認に、氏名、メールアドレス、プロフィールといった基本的なIdentityスコープだけを使うため、アクセス数に応じたOAuth利用料を見込む必要はありません。
Google Cloudの設定時には、支払い方法を登録した記憶があります。ただし、Googleの公式説明でも、請求先アカウントの関連付けは一部の有料APIを利用する場合に必要になるもので、OAuth認証そのものの利用料とは別です。支払い方法を登録したことだけで、このアプリのGoogle認証に料金が発生するわけではありません。
無料枠や料金体系は変更される可能性があるため、公開時には公式料金ページとCloudflare、Google Cloud双方の利用状況を確認する必要があります。
インストール後も更新と再ログインで迷わせない
PWAは一度インストールすると、ブラウザのタブとして使うよりも「普通のアプリ」に近く見えます。一方、Service Workerが古いファイルを保持するため、デプロイしただけでは利用中の画面がすぐ最新版へ切り替わらないことがあります。
そこで、Service Workerの更新を検出したら「新しいバージョンがあります」と表示し、利用者が「更新する」を押したタイミングで切り替えるようにしました。画面が再びアクティブになったときと、表示中は30分ごとにも更新を確認します。
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW()
if (!needRefresh) return null
return (
<button onClick={() => void updateServiceWorker(true)}>
更新する
</button>
)
Cloudflare Accessのセッション切れにも対応しました。Accessの認証画面がAPIリクエストへ返ると、ブラウザのfetchからはネットワークエラーに見えることがあります。端末がオンラインなのにAPIへ接続できない場合は、通常の再試行だけでなく「再ログイン」を表示し、認証をやり直せるようにしています。
厳密には、オンライン中のネットワークエラーがすべてセッション切れとは限りません。そのため「有効期限が切れた可能性があります」と案内し、再ログインと再試行の両方を残しています。原因を断定するのではなく、利用者がその場で復帰できる選択肢を示すための実装です。
また、PWAを日付をまたいでバックグラウンドから復帰した場合は、スナップショットの再取得と同時に表示基準日も更新します。通信に失敗してキャッシュを表示し続ける場合でも、「今日」「○日前」という最終購入日の表示だけは現在の日付で再計算します。
PWAの戻る操作
AndroidでインストールしたPWAでは、戻るボタンを検索欄を閉じるつもりで押したのに、アプリ自体が終了することがありました。
最終的には、次の一時的なUI状態がある場合だけ戻る操作で閉じるようにしました。
- 検索文字が入力されている
- メニューが開いている
- ダイアログや履歴画面が開いている
一時状態がない通常画面では、戻る操作を無理に横取りせず、PWAを終了させます。
生成AIは実装だけでなくテストにも入ってきた
このアプリのコードは生成AIに作ってもらい、その後の修正も生成AIとの対話で進めました。私は生成されたコードを詳しく読んで学習したわけではないため、ReactやTypeScriptのコーディング能力が大きく上がった実感はありません。一方で、PWA、IndexedDB、Cloudflare Workers、D1、Access、Google OAuthがどのような役割を持ち、どう組み合わさるかは、実際のシステムを作る中で知ることができました。
さらに驚いたのは、生成AIがコードを書くところだけでなく、実機テストにも入れるようになっていたことです。Android端末をUSBデバッグで接続すると、ADBを通じてアプリを開き、画面構造を取得し、操作して挙動を確認できました。ブラウザでも、ページを直接操作し、複数タブを使った共有動作まで確認できました。今回の既存アプリ調査でも、この方法で操作や同期を確かめています。
実機テストが生成AIの仕事になりつつあるのと同じように、要件定義や設計といった上流工程にも対象は広がっていくと思います。現在は「何に困っているか」「どの挙動なら実際に使えるか」を私が判断し、生成AIが実装や調査、テストを進めていますが、この分担も固定ではありません。優れた要件定義や設計、レビューの思考過程をエージェントとして再現できるなら、上流工程だけが自動化の外に残るとは限りません。
まとめ
IndexedDBのキャッシュや楽観的更新は、通信先を持つPWAを待たずに操作するために必要な土台でした。ただ、利用者にとって速く反応することは前提であり、それ自体がToggleListを使う理由ではありません。
実際に使っていて効いたのは、商品ごとの最終購入日、家族の操作がわかる変更履歴、日本語の表記揺れを吸収する検索といった細かな機能でした。一つひとつは小さくても、「最近買ったか」「誰が変更したか」「どう入力すれば見つかるか」という日常の迷いを減らしてくれます。
ToggleListは、最初から完成形を設計して作ったものではありません。家族で使い、そこで見つけた小さな不満を直すことを繰り返して現在の形になりました。自分たちに必要な細部を、自分たちの使い方に合わせて足していけることが、このアプリを作った一番の価値だと感じています。