utily.net のツールはすべてサーバーにデータを送らず、ブラウザ側でデータを保持します。ユーザーの個人情報をサーバーに預けない設計方針の結果として、6つのツールでブラウザのストレージをどう使うかをそれぞれ考える必要がありました。
結果として3種類のアプローチを使い分けています。localStorage が4ツール、IndexedDB(Dexie.js 経由)が1ツール、URL エンコードが1ツール(部分的に)。それぞれの判断理由と、実際に詰まったポイントを書きます。
3つのストレージアプローチ:全体像
| ツール | 主なストレージ | 理由 |
|---|---|---|
| エンジニア占い | localStorage | 「今日すでに確認した」フラグのみ保存 |
| Modtimer | localStorage | 設定値(倍率3つ)のみ保存 |
| FlowTick | localStorage | 日付別の時間記録データ(JSONシリアライズ) |
| 履歴書ジェネレーター | localStorage | Jotai の状態を永続化(+ Google Drive オプション) |
| Palette | localStorage + URLエンコード | 通常はlocalStorage、共有時はURLに状態をエンコード |
| GuideKnot | IndexedDB | 画像データ・ネスト構造・大量レコード対応が必要 |
localStorage で十分なケース:4ツールの判断
シンプルなルール:文字列に変換できて、5MB以下なら localStorage
localStorage は同期 API で扱いが単純です。localStorage.setItem(key, value) と localStorage.getItem(key) だけ覚えれば使える。非同期処理のことを考えなくていいのは、React のレンダリングサイクルとの相性が良い点です。
ただし localStorage の値は文字列のみです。オブジェクトや配列を保存するには JSON.stringify して保存し、取り出すときに JSON.parse する必要があります。これはボイラープレートですが、扱いに慣れると苦になりません。
エンジニア占い:ほぼ使っていない
占いの結果は日付をシード値にした疑似乱数で毎日決まります。localStorage に保存しているのは「今日の日付に占い結果を確認した」というフラグと、表示済みのメッセージIDだけです。ページをリロードするたびに再計算するより、一度確認したら同じ結果を返す方が自然だからです。
保存データのサイズは数十バイト。localStorage の 5MB 制限が問題になる余地がまったくありません。
Modtimer:設定値3つだけ
時・分・秒それぞれの速度倍率(最大3つの数値)を localStorage に保存しています。ページを閉じて再度開いたとき、前回設定した倍率が復元される必要があるからです。
これも設計としては非常にシンプルで、localStorage で正解でした。カスタムフックに localStorage の読み書きをラップして、useState と組み合わせるだけで実装できます。
FlowTick:JSONシリアライズで日付別データを管理
FlowTick では、日付ごとの作業記録(開始時刻・終了時刻・カテゴリ・メモのリスト)を保存します。1日あたりのデータ量は小さく、数年分を保存しても数百KBに収まります。
データ構造はこんな感じです。
FlowTick のデータ構造(概略):
{ "2026-04-25": [{ start: "09:00", end: "12:00", category: "開発", memo: "..." }, ...], "2026-04-24": [...] }
日付をキーにしたオブジェクトを JSON.stringify して localStorage に保存しています。カレンダー表示のときは日付キーで直接アクセスできるので、検索処理も不要です。
1点注意したのが localStorage のクォータ(容量上限)です。ブラウザによって異なりますが、概ね 5〜10MB です。FlowTick のデータは文字列なので、1エントリが 200〜300 バイト程度。365日 × 10エントリとして年間約1MBの計算です。数年分のデータが溜まっても問題ないと判断しました。
履歴書ジェネレーター:Jotai との組み合わせ
履歴書ジェネレーターでは Jotai を状態管理に使っています。Jotai はアトムベースの状態管理ライブラリで、アトムの状態を localStorage に永続化するための atomWithStorage という仕組みがあります。これを使うことで、「アトムの状態が変わったら自動的に localStorage に保存され、ページリロード時に復元される」という動作を、ほぼ追加コードなしで実現できます。
履歴書のデータは氏名・住所・学歴・職歴などを含みますが、テキストだけなのでサイズは数十KB程度です。localStorage で問題ありません。写真データは base64 エンコードすると数百KBになりますが、それでも上限内に収まります。
Undo/Redo の実装:履歴書ジェネレーターには Undo/Redo 機能があります。これは Jotai の optics と history プラグインを使って実装しています。変更履歴をメモリに持つため localStorage には保存しませんが、こうした「履歴が必要な場合」は localStorage の単純なキーバリューでは対応が難しく、適切なライブラリを選ぶことが重要です。
IndexedDB を選んだケース:GuideKnot
なぜ localStorage では足りなかったか
GuideKnot(マニュアル作成ツール)は、6つの中で一番データ構造が複雑です。手順書には以下のデータが含まれます。
- 複数の手順書ドキュメント(マニュアル一覧)
- 各手順書の中に、ネストしたステップの配列(分岐や子ステップを含む)
- 各ステップに関連付けられた画像データ(バイナリ)
- 実行追跡のための進捗状態
画像データが含まれる点が決定的でした。localStorage は文字列しか保存できないため、画像を保存するには base64 エンコードが必要です。一枚の画像が 数百KB〜 数MB になります。手順書が増え、各ステップに画像が添付されると、すぐに localStorage の上限を超えます。
また、マニュアルが増えると全データを一度に JSON.parse することになり、パフォーマンスの懸念もありました。IndexedDB はインデックスによる検索と非同期読み書きができるため、データが増えてもパフォーマンスを保てます。
IndexedDB を直接使わず Dexie.js を選んだ理由
IndexedDB のネイティブ API は、正直かなり使いにくいです。コールバックベースの古いAPIで、Promise を自分でラップする必要があり、スキーマの定義やバージョン管理も煩雑です。
Dexie.js は IndexedDB のラッパーライブラリで、Promise ベースのシンプルな API を提供します。スキーマの定義も直感的で、テーブルのインデックス設定も簡単です。
Dexie.js を使うと何が変わるか:
ネイティブ IndexedDB:const request = db.transaction(['manuals'], 'readwrite').objectStore('manuals').add(data); と書いてイベントリスナーを設定する。
Dexie.js:await db.manuals.add(data); と書くだけ。Promise が返るので async/await で扱える。
非同期 API との格闘
Dexie.js を使っても、IndexedDB が非同期であることは変わりません。React のコンポーネントでデータを読み込んで表示する流れを作るとき、「非同期でデータを取得してからレンダリングする」という順序の制御に苦労しました。
特に詰まったのが、「データを取得する前にコンポーネントがレンダリングされる」という状況です。useState の初期値が空で、useEffect 内で非同期にデータを取得して setState するパターンで、データ取得中にフォームの値が空になってしまう問題が出ました。
解決策は「データロード中」という状態を明示的に持つことでした。isLoading フラグが true の間はスケルトン表示にして、データ取得完了後に本来のUIを表示する。単純ですが、これを最初から設計に組み込まなかったことが混乱の原因でした。
URL エンコード:Palette の共有機能
アカウント不要で共有するためのアプローチ
Palette(クリップボード管理)には「URLで共有する」機能があります。自分が登録したテキストコレクションを、URLを送るだけで相手に渡せます。サーバーを使わずにこれを実現するには、状態をURLに含めるしかありません。
実装方法はシンプルで、タブの内容を JSON にシリアライズして Base64 エンコードし、URL のクエリパラメータとして付与します。受け取った側は URL からパラメータを取り出してデコードし、タブの内容として復元します。
URL の長さ制限が壁になった
URLには長さの上限があります。ブラウザや経路によって異なりますが、概ね 2000〜8000 文字程度が安全な上限とされています。テキストの量が増えると、URL が長くなりすぎて共有できなくなります。
現在は警告を出して対処していますが、根本的な解決策はありません。「バックエンドなしで無制限に共有する」という要件と「URLの長さ制限」は、根本的に矛盾しています。
URLエンコードの限界:テキストの総量が増えると URL が長くなり、一部のサービス(SNS やメッセージアプリ)でリンクが切れることがあります。大量のテキストを共有する場合は、テキストファイルとしてエクスポートして渡すことをおすすめします。
localStorage との使い分け
Palette では「自分が使うときは localStorage、人と共有するときは URL」という使い分けをしています。通常の使い方では、入力したテキストは自動的に localStorage に保存され、ページを閉じて再度開いても内容が残ります。「共有ボタン」を押したときだけ、現在の状態をURLに変換します。
3つのアプローチを選ぶ判断基準
6つのツールを作った経験から、ブラウザストレージを選ぶ判断フローを整理しました。
- データが文字列(またはJSONシリアライズ可能)で、数MB以内に収まる → localStorage。非同期処理のオーバーヘッドなしにシンプルに扱える。
- バイナリ(画像・ファイル)を保存する必要がある → IndexedDB 一択。localStorage には保存できない。
- データ量が多く、一部だけを効率的に読み書きしたい → IndexedDB。インデックスによる検索が使える。
- URLで他のユーザーと状態を共有したい → URL エンコード。ただしデータ量の上限を意識する。
- タブをまたいでデータを共有したい(同一オリジン内) → localStorage(BroadcastAPI との組み合わせも検討)。
実際に困ったこと:localStorage の落とし穴
JSON.parse のエラーハンドリングを忘れない
localStorage から取り出したデータを JSON.parse するとき、保存されているデータが壊れているケースがあります(ブラウザのバグ、手動での localStorage クリアなど)。JSON.parse が例外を投げると、アプリ全体がクラッシュします。try-catch を必ず入れるか、Zod などのバリデーションライブラリでデータを検証することをすすめます。
プライベートブラウジングでの動作
Safari のプライベートブラウジングでは localStorage の書き込みが制限されることがあります(例外を投げるブラウザもあります)。setItem を呼ぶときも try-catch を入れておくと、プライベートブラウジングのユーザーへの影響を最小限にできます。
複数タブで開いたときのデータ競合
localStorage はタブ間で共有されます。複数タブで同じツールを開いて編集すると、後から保存した方が前の状態を上書きします。utily.net のツールはシングルタブでの利用を前提にしており、この問題は許容範囲としていますが、将来的には対策が必要かもしれません。
IndexedDB の落とし穴
スキーマのバージョン管理
IndexedDB はスキーマにバージョン番号があります。スキーマを変更(フィールドの追加・削除)するときは、バージョン番号を上げて onupgradeneeded イベントで移行処理を書く必要があります。Dexie.js はこれを簡略化してくれていますが、移行ロジックに誤りがあると既存ユーザーのデータが壊れる可能性があります。
実際に開発中に一度やらかしました。バージョン番号を上げずにスキーマを変更しようとして、既存データと新スキーマの不整合でエラーが出ました。以来、スキーマ変更はバージョン番号と移行処理をセットで必ず書くようにしています。
Safari の IndexedDB 制限
Safari は IndexedDB の実装に特有の制限があることがあります。特にプライベートブラウジングモードでは IndexedDB が使えない(例外を投げる)場合があります。GuideKnot を Safari のプライベートモードで開いてエラーになることを発見したのは、リリース後のことでした。フォールバック処理を追加しました。
よくある質問
Q. ブラウザのキャッシュを消すとデータも消えますか?
「キャッシュを消す」という操作の対象によります。「Webサイトのデータを削除」や「ストレージを削除」を選択すると、localStorage と IndexedDB のデータが消えます。「キャッシュのみ削除」では消えないことが多い。ブラウザの設定画面でサイトごとのデータを確認できます。
重要なデータは定期的にエクスポート機能でバックアップすることをおすすめします。各ツールにはデータのエクスポート機能を設ける予定です。
Q. スマートフォンでもデータは保存されますか?
はい、スマートフォンのブラウザでも localStorage と IndexedDB は使えます。ただし、iOS Safari はストレージのクリアポリシーが PC ブラウザより積極的で、長期間アクセスしないとデータが削除されることがあります。
Q. なぜ Firebase や Supabase などのクラウドデータベースを使わないのですか?
ユーザーのデータをサーバーに送らないという設計方針が理由です。クラウドデータベースを使えば機能的には豊かになりますが、「誰がどんな履歴書を書いたか」「どんなマニュアルを持っているか」という情報が外部に出ることになります。それを避けるためにブラウザ完結を選んでいます。
Q. 将来的にクラウド同期は実装しますか?
Google Drive 連携(履歴書ジェネレーターで実装済み)のような形で、ユーザーが自分で選択できる同期オプションを追加することは検討しています。強制的にクラウドに送ることはしません。
まとめ
6つのツールを作って学んだことは、「ストレージの選択はデータの性質に合わせる」というシンプルな原則です。
localStorage は手軽で十分なケースが多い。IndexedDB は画像や大量データが必要なときの答え。URL エンコードは共有機能を実現する唯一のサーバーレス手段。それぞれの特性と制限を理解して使い分けることが、設計上の重要な判断でした。
「とりあえず localStorage」で始めてみて、問題が出たら考えるというアプローチも悪くありません。ただし、IndexedDB への移行が必要になったときは移行コストがかかります。最初から「このツールにはどのストレージが合っているか」を考える時間は、後から节约できるデバッグ時間より短いことが多い。