localStorageのフォーム自動保存はどう設計するか(機密データ・復元UIの判断つき)

はじめに

入力量の多い業務フォームで、リロードやセッション切れの事故から入力を守りたい。localStorage への自動保存は数行で書けるので、まず素朴に「フォーム全体を保存して、開いたら戻す」を実装しがちです。ところが、この素朴版は次の3つの事故を起こします。

  1. パスワードやマイナンバーが localStorage に平文で残り、共有PCやXSSで読まれてしまう
  2. ページを開いた瞬間に古い下書きが自動で流し込まれ、別タブで進めた最新の入力を上書きしてしまう
  3. 送信が成功しても下書きが残り続け、次回に前の内容が復元されて混乱する

症状は3つですが、原因は1つです。「どこに保存し、何を保存せず、いつ消すか」という設計を決めないまま、フォーム全体をそのまま保存しているからです。この記事では、下書き自動保存を業務フォームで安全に使うための判断を、保存先・機密データの除外・復元UI・クリアのタイミングの順に整理します。localStorage の基礎的なしくみ(Cookieとの違いなど)はCookieとローカルストレージの違いを簡単解説にまとめてあるので、必要ならそちらを先に読んでください。

保存先の選び方(localStorage / sessionStorage / サーバー)

最初の判断は保存先です。「タブやブラウザを閉じても復元したいか」で選び方が決まります。

保存先 タブ/ブラウザを閉じても残るか 目安の容量 向いている用途
localStorage 残る(明示的に消すまで永続) 5MB前後(目安) 書きかけを翌日も復元したい問い合わせ・申請フォーム
sessionStorage 残らない(タブを閉じると消える) 5MB前後(目安) 同一セッション内の誤リロードだけ救えれば十分な入力
サーバー保存 残る(アカウントに紐づく) サーバー次第 複数端末で下書きを共有したい、機密を含む下書きを預けたい

業務フォームの「うっかり離脱から守る」用途では localStorage が基本です。共有端末で「そのタブの間だけ守れれば十分」なら sessionStorage にすると、閉じた時点で自動的に消えるぶん機密の残留リスクが下がります。複数端末をまたいで下書きを引き継ぎたい、あるいは機密を含む下書きを扱うなら、ブラウザではなくサーバーに預けるのが本筋です。ただしサーバーに置けば安全というわけではなく、認証・アクセス制御・保存時の暗号化・保存期限の設定・アクセスの監査といった保護があって初めて機密を預けられます。容量は1オリジンあたり5MB前後が一般的な目安ですが、ブラウザや保存方式、環境によって変わります。文字だけの下書きなら十分ですが、画像をBase64で持つとすぐ超えるので、大きなデータは対象外にします。

保存のタイミングとdebounce

いつ保存するかは、input イベントを debounce(入力が止まってから一定時間後にまとめて実行)で間引くのが扱いやすい形です。1文字ごとに localStorage へ書くと書き込みが多すぎ、逆に blur(フォーカスを外したとき)だけだと、入力途中でタブを閉じた分を取りこぼします。

let timer;
form.addEventListener('input', function () {
  clearTimeout(timer);
  // 入力が止まって500msたったら保存する
  timer = setTimeout(saveDraft, 500);
});

500msあたりが目安です。保存が走ったら「HH:MM に保存しました」のように状態を小さく表示すると、ユーザーが「勝手に保存されている」ことを把握できて安心感につながります。

機密データを保存しない設計(ホワイトリスト方式)

ここがこの記事のいちばん大事なところです。localStorage は暗号化されない、ただのテキストの置き場です。開発者ツールを開けば誰でも中身を読めますし、同じ端末を使う別の人も、ページにXSSの穴があれば攻撃者のスクリプトも、同じ文字列を読めます。パスワード・クレジットカード番号・マイナンバーのような機密は、下書きであっても localStorage に入れてはいけません。

どれくらい無防備かは、実際に保存して覗いてみるのが早いです。下のデモで「素朴に全項目を保存」を押すと、パスワード欄の値がそのまま localStorage に残ります。

localStorage の中身(開発者ツールで見えるのと同じ生データ)
(まだ保存していません)

このデモはあなたのブラウザの localStorage に一時的に書き込みます。「localStorageを消す」で削除できます。

「ホワイトリストで保存」を押すと、保存対象を氏名とメールアドレスだけに絞るので、パスワードは localStorage に一切残りません。ここでのポイントは、除外の考え方に2つの方向があることです。

ブラックリスト方式(保存しない項目を除外する)

「この項目は保存しない」と印を付けて外す書き方です。手軽ですが、新しい機密フィールドを足したときに印を付け忘れると、そのまま保存されてしまいます。安全側に倒れないのが弱点です。

ホワイトリスト方式(保存してよい項目だけ列挙する)

「保存してよい項目」を明示的に並べ、それ以外は保存しない書き方です。列挙し忘れた項目は保存されない、つまり初期状態が安全側なので、機密を扱うフォームではこちらを推奨します。

// ブラックリスト方式:印の付いていない項目を全部保存する
// → 機密フィールドに data-no-save の付け忘れがあると漏れる
var data = {};
form.querySelectorAll('[name]:not([data-no-save])').forEach(function (el) {
  data[el.name] = el.value;
});

// ホワイトリスト方式:保存してよい項目だけを列挙する(推奨)
// → 列挙し忘れた項目は保存されない=初期状態が安全
var SAVABLE = ['name', 'email', 'subject', 'body'];
var data = {};
SAVABLE.forEach(function (key) {
  var el = form.elements[key];
  if (el) data[key] = el.value;
});

保存してよい/いけないフィールドの線引きは、業務内容によりますが、目安は次のとおりです。

フィールド 保存してよいか 理由
氏名・件名・本文・メモ 保存してよい 失うと痛い。下書き保存の主目的そのもの
メールアドレス・電話番号 フォーム次第で判断 個人情報なので、フォームの性質・利用端末・社内規程を踏まえて判断する
パスワード・ログイン情報 保存しない 平文で残る。漏れたときの被害が大きい
クレジットカード番号・セキュリティコード 保存しない 保持すること自体が規約・法令に触れうる
マイナンバー・その他の個人番号 保存しない 法令で厳格な管理が求められる

コピペで動く下書き保存フォームの実体は下書き自動保存フォーム(localStorage復元)にあります。こちらは data-no-save 属性を付けた項目を保存対象から外すブラックリスト方式で、除外すべき機密フィールドがはっきりしているフォームなら手軽に導入できる形です。扱う機密が多いフォームや、項目が増えていくフォームでは、上で示したように収集部分をホワイトリスト方式へ置き換えると、付け忘れによる漏れを防げます。

復元UIの出し方(自動復元とバナー確認)

次の判断は「復元をどう出すか」です。ページを開いた瞬間に前回の下書きを自動で流し込む実装をよく見かけますが、これはおすすめしません。ユーザーが新規に書き直したいときや、別タブ・別セッションで進めた最新の入力を、古い下書きで黙って上書きしてしまう事故が起きるからです。

推奨は「前回の下書きがあります」バナーを出して、ユーザーに戻すか選ばせる形です。[復元する] を押したときだけ値を戻し、[破棄する] なら下書きを消します。自動で書き換えないので、上書き事故が起きません。

// 自動で流し込まない。下書きがあればバナーを出して選ばせる
var saved = loadDraft();
if (saved) {
  showRestoreBanner({
    onRestore: function () { fillForm(saved); },   // 押されたときだけ戻す
    onDiscard: function () { clearDraft(); }        // 破棄なら消す
  });
}

この挙動は下書き自動保存フォーム(localStorage復元)で実際に触れます。入力してリロードし、バナーから復元される流れを確認できます。

下書きを消すタイミング

保存と同じくらい大事なのが「いつ消すか」です。消し忘れると、送信が終わった内容が次回に復元されて混乱します。契機ごとに整理すると次のようになります。

契機 下書きをどうするか 補足
送信が成功したとき 消す 残すと次回に古い内容が復元される。成功レスポンスを受けてから消す
ユーザーが「破棄」を選んだとき 消す 確認を挟むかは任意。誤操作が怖いなら一度確認する
古くなった下書き 復元時に破棄する savedAt を一緒に保存し、例えば7日を超えたら無効にする
別タブで保存・破棄されたとき 同期する storage イベントで検知する(次の節)

送信成功時のクリアは、通信の成功を確認してから行います。送信ボタンを押した瞬間に消すと、サーバーエラーで送信が失敗したときに下書きまで失います。

複数タブで開いたときの競合

業務では同じフォームを2つのタブで開くことがあります。両方が同じキーへ保存すると、後から保存したタブの内容で上書きされる「後勝ち」になります。素朴な実装では、片方のタブで書いた下書きが、もう片方の保存で静かに消えます。

これを検知したいときは storage イベントを使います。このイベントは自分のタブでは発火せず、同じオリジンの別タブが localStorage を変更したときにだけ通知されるという性質があり、他タブの更新を知るのにちょうど向いています。

window.addEventListener('storage', function (event) {
  if (event.key !== 'form-draft') return;
  // 別タブで下書きが更新された。必要ならユーザーに知らせる
  // 例:「別のタブでこのフォームが編集されました」の注意を出す
});

ただし storage イベントでできるのは競合の「検知」までで、どちらの下書きを残すかという「解決」まではしてくれません。解決したいときは、保存データに savedAt(保存時刻)やタブごとのIDを一緒に持たせておき、より新しいほうを優先する、あるいは古いタブには「別のタブで更新されました」と知らせて再読込を促す、といった方針を自分で決めます。

そこまで厳密な競合解決を作り込むかは、フォームの重要度しだいです。多くの業務フォームでは、後勝ちで上書きされうることを踏まえ、別タブ更新を検知したら注意を出す程度で足ります。凝った同期が必要になったら、それはブラウザ保存ではなくサーバー保存に寄せるサインです。

まとめ

下書き自動保存は数行で書ける一方、素朴に作ると機密の残留・古い下書きでの上書き・送信後の残留といった事故につながります。設計として押さえるのは、保存先を用途で選ぶこと、機密はホワイトリスト方式で最初から保存しないこと、復元は自動ではなくバナーで選ばせること、送信成功や期限切れで確実に消すこと、この4点です。

離脱そのものを止める beforeunload の確認ダイアログは、仕様上どこまで整えても確実には出せません(beforeunloadの確認ダイアログが出ない・効かない原因で解説しています)。離脱を止めるより、下書き保存で「離脱されても復元できる」ようにするほうが確実です。コピペで動く実装は下書き自動保存フォーム(localStorage復元)にまとめてあります。

関連するUI事例

下書き保存・入力保護が関わる代表的なUI事例です。いずれもコピペで動くサンプルを掲載しています。