ブラウザでフォームの状態を永続化する方法
10分かけて複数ステップにわたる求人応募フォームを入力したとします。誤って戻るボタンを押してしまい、すべてが消えてしまった。
これはWeb開発における最もフラストレーションの溜まるUXの失敗の一つですが、完全に防ぐことが可能です。本記事では、状況に応じた適切なストレージメカニズムを用いて、ブラウザ上でフォームの状態を永続化する方法を解説します。
重要なポイント
- シングルページアプリケーションでは、DOMがキャッシュから復元されるのではなく一から再レンダリングされることが多いため、ナビゲーション時にフォームデータが失われやすい。
- 保存期間のニーズに応じてストレージを選択する: 長期的なドラフトには
localStorage、単一タブのセッションにはsessionStorage、大規模または構造化されたデータには IndexedDB。 - オートセーブの基本パターンはシンプル: input イベントをデバウンスし、ストレージに保存、マウント時に復元、送信成功時にクリアする。
- パスワード、トークン、決済情報を Web Storage に保存してはいけない — XSS 攻撃に対して脆弱である。
- ストレージへの書き込みは必ず try/catch で囲み、
QuotaExceededErrorやパーティション化されたストレージにも適切に対応する。
なぜ最新アプリではフォームデータが消えるのか
従来のサーバーレンダリングされたページでは、若干の救いがあります。ブラウザはページ自体をキャッシュしているため、戻るナビゲーション時にフォームの値を復元することがしばしばあります。しかし、シングルページアプリケーションでは必ずしもその恩恵を受けられません。JavaScript がフォームを一から再レンダリングする場合、ブラウザに復元すべきキャッシュ済み DOM が存在しない可能性があり、フィールドが空のまま戻ってしまうのです。
解決策は、自分でフォームの状態をブラウザストレージに保存し、フォームのマウント時に復元することです。
適切なストレージメカニズムの選択
すべての永続化問題に同じ解決策が当てはまるわけではありません。以下は実用的な比較です:
| 方法 | 保存期間 | タブ分離 | サイズ制限 | 最適な用途 |
|---|---|---|---|---|
localStorage | 手動で削除するまで | なし | 約 5〜10 MB | 長期的なドラフト |
sessionStorage | タブを閉じるまで | あり | 約 5〜10 MB | 単一セッションのフォーム |
IndexedDB | 手動で削除するまで | なし | ブラウザに依存 | 大規模または構造化データ |
| History API state | ナビゲーションエントリ | あり | 小さなオブジェクト | SPA の戻る/進むナビゲーション |
localStorage は、ブログ記事のエディターや長い登録フォームなど、ブラウザ再起動後も残しておきたいドラフトに適しています。sessionStorage は、同一タブ内でのリフレッシュにのみ耐えられればよく、タブをまたぐ必要がない場合に向いています。IndexedDB によるフォームドラフトの保存は、リッチコンテンツ、ファイルメタデータ、または JSON 文字列では扱いにくい複雑なネスト構造のオブジェクトを保存する場合に意味があります。
localStorage と sessionStorage はどちらも同期的で文字列ベースであるため、すべての読み書きがメインスレッドをブロックし、JSON.stringify/JSON.parse を必要とします。IndexedDB は非同期で、構造化データをネイティブに扱えるため、単純なキー・バリューペアを超えるものには適しています。これら3つはすべて最新のブラウザで十分にサポートされています。
Discover how at OpenReplay.com.
localStorage を用いたオートセーブの実装
基本パターンはシンプルです: 入力時に保存、ロード時に復元、送信成功時にクリアです。
const DRAFT_KEY = 'contact_form_draft';
const form = document.querySelector('#contact-form');
// Autosave with debounce
let saveTimer;
form.addEventListener('input', (e) => {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
const formData = Object.fromEntries(new FormData(e.currentTarget));
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(formData));
} catch (err) {
if (err.name === 'QuotaExceededError') {
console.warn('Storage full, draft not saved');
}
}
}, 500);
});
// Restore on load
window.addEventListener('DOMContentLoaded', () => {
const saved = localStorage.getItem(DRAFT_KEY);
if (!saved) return;
try {
const draft = JSON.parse(saved);
Object.entries(draft).forEach(([name, value]) => {
const field = form.querySelector(`[name="${name}"]`);
if (field) field.value = value;
});
} catch {
localStorage.removeItem(DRAFT_KEY);
}
});
// Clear after successful submit
form.addEventListener('submit', () => {
localStorage.removeItem(DRAFT_KEY);
});
保存呼び出しをデバウンスする(ここでは500ms)ことで、キーストロークごとにストレージに過剰なアクセスをすることを防ぎます。setItem は必ず try/catch で囲んでください — ブラウザは特にストレージの空き容量が少ない環境やサードパーティのストレージがパーティション化されている場合に QuotaExceededError をスローする可能性があり、実際にスローします。同様に、破損したドラフトが例外をスローして復元処理を破綻させないよう、JSON.parse も try/catch で囲むのが賢明です。
保存してはいけないもの
パスワード、決済カード番号、認証トークン、その他あらゆる機密性の高い個人情報を localStorage や sessionStorage に永続化してはいけません。これらの API はページ上で実行される任意の JavaScript からアクセス可能であり、XSS 攻撃に対して脆弱です。フォームが機密フィールドを収集する場合は、それらをドラフトロジックから完全に除外してください。
また知っておくべきこととして、組み込みやサードパーティのコンテキストでは、ブラウザが Web Storage へのアクセスをパーティション化または制限することが増えています。ストレージが常に利用可能であると想定せず、利用可能か確認し、適切にフォールバックするようにしてください。
ドラフトのクリアとエッジケース
フォームの送信が成功した後は、必ずドラフトを削除してください。予期せず再表示される古いドラフトはユーザーを混乱させます。フォーム構造が時間とともに変わる可能性がある場合は、古いドラフトが復元時にサイレントなエラーを引き起こさないよう、ストレージキーのバージョン管理(例: contact_form_draft_v2)を検討してください。
まとめ
ブラウザでのフォーム永続化には、ライブラリもバックエンドも必要ありません。少量の慎重な JavaScript — デバウンスされた保存、安全な復元、送信時のクリーンアップ — があれば、データ損失を防ぎ、フォームを目に見えて信頼性の高いものにすることができます。
よくある質問
長いフォーム、ブログエディター、または数日後にユーザーが戻ってくる可能性のある複数ステップのアプリケーションなど、ブラウザ再起動後もドラフトを残したい場合は localStorage を使用してください。同一タブ内での誤ったリフレッシュにのみ耐えれば十分で、タブを閉じたら消えてほしい場合は sessionStorage を使用してください。両者は同じ API を共有しているため、切り替えは1行の変更で済みます。
ファイル入力はセキュリティ上の理由からプログラム的に復元することができません。ブラウザはファイル入力の値を設定することを許可しません。ユーザーがファイルをアップロードする場合は、ファイルのメタデータを保存するか、すぐにサーバーへアップロードして、得られた参照 ID を永続化してください。クライアント側に保持する大きなファイルについては、送信されるまで File または Blob オブジェクトを直接保存するために IndexedDB を使用してください。
一般的なフォームでは影響ありません。localStorage への書き込みは小さなペイロードであれば高速で、入力イベントを約500ミリ秒でデバウンスすれば書き込み頻度を抑えられます。問題が現れるのは、ドラフトが大きくなりすぎる場合や、デバウンスせずキーストロークごとに保存が走る場合で、各書き込みがメインスレッドをブロックします。大規模または構造化されたデータについては、非同期かつノンブロッキングな IndexedDB に切り替えてください。
window オブジェクトの storage イベントをリッスンしてください。あるタブで localStorage が変更されると、他のタブでこのイベントが発火し、キーと新しい値が渡されます。リッスン側のタブでは、それに応じてフォームフィールドを更新できます。なお、storage イベントは変更を行ったタブでは発火せず、同じオリジンを表示している他のタブでのみ発火する点に注意してください。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.