フォームの入力内容を保存して復元する — 難しいのは「いつ保存するか」と「いつ消すか」

フォームの入力内容を保存して復元する — 難しいのは「いつ保存するか」と「いつ消すか」

先に結論

「先に結論」の内容を図にまとめたもの

入力内容を保存して復元する処理そのものは、10行ほどで書けます。 解説記事もたくさんあります。詰まるのは、そこではありません。

表1:判断が要る3点
素直にやると正しくは
どこに保存するかCookieStorage。Cookieは毎回サーバーに送られる(詳しく)
いつ保存するかbeforeunload で離脱時にまとめてvisibilitychange。beforeunloadは発火しないことがある(詳しく)
いつ消すか考えていない送信が成功したら消す(詳しく)

MDNが beforeunload についてはっきり書いています。

「このイベントは特にモバイルプラットフォームでは、確実に発生するとは限りません。 例として、モバイルユーザーが別のアプリに切り替え、その後アプリマネージャーからブラウザーを閉じると、 beforeunload イベントは発生しない」
「アプリの状態自動保存をより確実に実行するための代替として、 visibilitychange イベントの使用が推奨される」
— Window: beforeunload イベント(MDN)

「入力途中でアプリを切り替えて、そのままブラウザを閉じる」のは、 スマホでは珍しくありません。そこで保存されないなら、何のための自動保存か分かりません。

Storageそのものの使い方・容量・Safariが7日で消す話は localStorage の使い方と、2026年に気をつけること に書きました。 この記事は「フォームの保存」に絞ります。

Cookieには入れない

「Cookieには入れない」の内容を図にまとめたもの

2.9倍に膨らむ

Cookieに入れると、中身がそのまま入るわけではありません。 値をURLエンコードする必要があるので、日本語は大きく膨らみます。

現実的なフォーム1件ぶんで測りました。

表2:フォーム1件を入れたときの大きさ(Chrome 152で実測)
状態大きさ
元のJSON365文字 / 889バイト
Cookieに入れた状態(URLエンコード後)2,539文字
膨張率約2.9倍

測ったのは氏名・メール・電話・郵便番号・住所・本文という、 問い合わせフォームによくある組み合わせです。 特別に長いデータではありません。

Cookie 1つあたりの上限はおおむね4KB程度とされています。 2,539文字という時点で、その枠の半分以上を1つのフォームで使っています。

しかも毎回送られる

Cookieの本質的な問題はサイズではなく、サーバーに送られることです。 保存したいだけなのに、同じドメインへのリクエストすべてに付いていきます。

表3:1ページ表示で送られる量
同一オリジンのリクエスト数Cookieが2,539バイトなら
このサイトの記事ページ(実測)2(HTML+CSS)約5KB
画像・JS・フォントが並ぶ普通のサイト数十その数十倍

正直に書くと、このサイトは画像もJavaScriptも無いので2リクエストしかありません。 実測値としては小さく出ています。 画像やスクリプトが数十個並ぶ一般的なサイトでは、この数十倍が毎ページ乗ります。

表4:保存先の選び方
保存先サーバーに送られるかフォームの一時保存に
localStorage送られない向く。タブを閉じても残る
sessionStorage送られない向く。タブを閉じると消えるので、消し忘れが起きない
Cookie毎リクエスト送られる向かない
サーバーに下書き保存—別端末から続けたいなら。実装は重い

「タブを閉じたら消えていい」なら sessionStorage が安全です。 消す処理を書き忘れても、勝手に消えます(詳しく)。

いつ保存するか

「いつ保存するか」の内容を図にまとめたもの

普通に遷移すると、全部発火する

ページを離れるときに発火するイベントは複数あります。 普通にリンクをクリックして遷移した場合に、どれが発火するかを測りました。

表5:通常のページ遷移での発火(Chrome 152で実測)
順イベント発火
1beforeunloadした
2pagehideした
3visibilitychange(hidden)した
4unloadした

全部発火します。だから開発中は問題が見えません。 beforeunload で保存するコードを書いて、手元で試して、動いた、で終わってしまいます。

beforeunloadは発火しないことがある

問題が出るのは、開発中には再現しにくい状況です。 MDNが具体例を挙げています。

表6:beforeunload が発火しない状況
状況MDNの記載
別アプリに切り替えて、アプリマネージャーからブラウザを閉じる発火しない
モバイル全般「確実に発生するとは限りません」

スマホでフォームを書いている途中に電話が来る、という場面を想像してください。 アプリを切り替えて、戻ってこずにブラウザを終了する。 自動保存が一番役に立つべき場面で、保存されていません。

visibilitychange は、画面が隠れた時点で発火します。 アプリを切り替えた瞬間がそれです。その後どうなっても、保存はもう済んでいます。

置くだけで遅くなる副作用もある

beforeunload には、もう1つ代償があります。

「Firefoxでは、beforeunload リスナーが設定されているページは bfcache に保存されず、 性能に悪影響を与える」
— Window: beforeunload イベント(MDN)

bfcache は、戻るボタンでページを瞬時に表示するための仕組みです。 beforeunload を1つ置いただけで、それが効かなくなります。 「保存のために置いたリスナーが、戻る操作を遅くする」という形になります。

表7:どのイベントを使うか
イベント確実性副作用使うか
visibilitychange(hidden)高いなしこれを使う
pagehide高いなし併用してよい
beforeunloadモバイルで落ちるbfcacheが効かなくなる保存には使わない
unload低いbfcacheが効かなくなる使わない
input(都度保存)最も確実次章で検証併用する

beforeunload を完全に捨てるわけではありません。 「保存していない変更があります。本当に離れますか」という確認ダイアログを出す用途には、これしかありません。 保存の処理はそこに置かない、という話です。

「毎回保存は重い」は本当か

「毎回保存は重い」は本当かの内容を図にまとめたもの

「input のたびに保存すると重いから、離脱時にまとめて保存する」という説明をよく見ます。 本当にそうなのか測りました。

まず、1文字打つと何回イベントが出るのか。

表8:入力とイベント回数(実測)
入力した文字input の発火回数
「こんにちは」(5文字)5回

1文字につき1回です。では、その1回の保存にどれだけかかるのか。

表9:保存のコスト(Chrome 152で実測・889バイトのJSON)
測ったもの結果
setItem 500回1.9ms
setItem 1回あたり0.004ms
100文字入力して100回保存した場合合計 0.38ms

「毎回保存は重い」は、少なくともこの規模では成り立ちませんでした。 100文字打って100回保存しても0.38ミリ秒です。 離脱時にまとめる理由を、性能に求めるのは間違いです。

つまり、素直に毎回保存してよいということです。 そのうえで visibilitychange でも保存しておけば、取りこぼしが減ります。 どちらか一方を選ぶ必要はありません。

なお、これは文字列を1つ書くだけの測定です。 保存のたびにフォーム全体を走査して JSON.stringify するなら、その分は乗ります。 気になるなら、そこを測ってください。保存そのものは問題になりません。

何を保存しないか

「何を保存しないか」の内容を図にまとめたもの

ここが一番事故につながります。フォームを全部まとめて保存すると、入れてはいけないものが混ざります。

表10:保存してはいけないもの
項目理由
パスワード平文で端末に残る。絶対に保存しない
クレジットカード番号・セキュリティコード同上。そもそも自前で扱わない
マイナンバー・運転免許番号などの識別番号残っていい情報ではない
確認用コード・ワンタイムパスワード保存する意味がなく、危険だけが残る
hidden のトークン類復元すると期限切れで逆に壊れる
ファイル選択(type="file")そもそも値を復元できない

ここで方式の選択が効いてきます。 「保存するものを明示する」のか、「保存しないものを除外する」のかです。 分かれ目は、あとからフォームに項目が増えたときに何が起きるかです。

表11:どちらの方式にするか
方式項目が増えたとき評価
保存するものを明示する(許可制)保存されないだけ。害はないこちらにする
保存しないものを除外する(拒否制)新しい項目が自動で保存される。機微な項目だと事故避ける

許可制にしてください。 「保存されなくて不便」で済むのと、「保存されてはいけないものが残る」のとでは、被害が違います。

いつ消すか

「いつ消すか」の内容を図にまとめたもの

ここを書いていない解説が大半です。 保存する処理だけ入れて消さないと、こうなります。

表12:消さないと起きること
場面起きること
送信が成功したあと次に同じフォームを開くと、送信済みの内容が復元される。二重送信の元
共有のパソコン次に使う人の画面に、前の人の入力が出る
フォームの項目を変更したあと古い形のデータが復元され、画面が壊れる
時間が経ったあと半年前の下書きが突然出てくる

消すタイミングは3つ決めておけば足ります。

表13:消す契機
契機やること
送信が成功したらすぐ消す。送信前ではなく、成功を確認してから
保存から一定期間が過ぎていたら復元せずに消す(保存時に時刻を一緒に入れておく)
フォームの形が変わっていたらバージョン番号を一緒に保存し、違えば捨てる

1行目は「成功したら」です。 送信ボタンを押した時点で消すと、通信が失敗したときに入力が消えます。 一番やってはいけない壊れ方です。

まとめたコード

「まとめたコード」の内容を図にまとめたもの

ここまでの判断を全部入れた形です。

const KEY = 'draft:contact';
const VERSION = 2;                       // フォームの形を変えたら上げる
const MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7日
const FIELDS = ['name', 'email', 'tel', 'body'];   // 許可制。ここに無いものは保存しない

const form = document.querySelector('#contact');

function save() {
  const data = {};
  for (const n of FIELDS) {
    const el = form.elements[n];
    if (el) data[n] = el.value;
  }
  try {
    localStorage.setItem(KEY, JSON.stringify({ v: VERSION, t: Date.now(), data }));
  } catch {
    // プライベートモードや容量超過で落ちることがある。保存できなくても止めない
  }
}

function restore() {
  let saved;
  try { saved = JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return; }
  if (!saved) return;
  if (saved.v !== VERSION) return clear();          // 形が変わっている
  if (Date.now() - saved.t > MAX_AGE) return clear(); // 古い
  for (const [n, v] of Object.entries(saved.data)) {
    const el = form.elements[n];
    if (el) el.value = v;
  }
}

function clear() { try { localStorage.removeItem(KEY); } catch {} }

// 復元は最初に1回
restore();

// 保存は入力のたび(実測で0.004ms。まとめる必要はない)
form.addEventListener('input', save);

// 画面が隠れた時点でも保存する。beforeunload は使わない
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') save();
});

// 送信が「成功したら」消す。押した時点ではない
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const res = await fetch(form.action, { method: 'POST', body: new FormData(form) });
  if (res.ok) clear();
});
表14:このコードで押さえていること
行何のため
FIELDS の配列許可制。パスワード等が混ざらない(表11)
v: VERSIONフォームの形が変わったら捨てる
t: Date.now()古い下書きを復元しない
try/catch保存に失敗しても入力を止めない
visibilitychangebeforeunload の代わり(MDNの推奨)
if (res.ok) clear()成功してから消す。失敗時に入力を失わない

AIに任せるときの注意

「AIに任せるときの注意」の内容を図にまとめたもの
表15:AIへの頼み方
頼むこと向くか理由
「保存する項目を配列で指定する形にして」向く許可制への書き換えは機械的
「バージョンと保存時刻を持たせて」向く定型。入れ忘れを潰せる
フォームのHTMLを貼って「保存してはいけない項目は?」向くtype="password" 等は読めば分かる
「フォームの自動保存を実装して」向かないbeforeunload と拒否制で返ってくる。どちらも避けたい形
「どのイベントで保存すべき?」向かない古い記事が多い。MDNの記載を見る
消すタイミングの設計向かない「送信後」か「成功後」かは、仕様の判断

判定の目安です。出てきたコードに beforeunload で保存する処理があったら、置き換えてください。 visibilitychange になっていれば現代的です。

よくある質問

Cookieではだめですか

動きますが向きません。同じドメインへのリクエストすべてに付いていきます(表3)。 測ったところ、889バイトのJSONがCookieでは2,539文字になりました(表2)。

beforeunloadで保存してはいけないのですか

保存には使わないでください。 MDNが「モバイルプラットフォームでは確実に発生するとは限りません」と明記しています(詳しく)。 離脱の確認ダイアログを出す用途には、いまもこれを使います。

input のたびに保存すると重くないですか

重くありません。実測で setItem 1回が0.004ms、 100文字入力しても合計0.38msでした(表9)。 まとめる理由を性能に求めるのは間違いです。

localStorageとsessionStorageはどちらがいいですか

「タブを閉じたら消えていい」なら sessionStorage が安全です(表4)。 消す処理を書き忘れても勝手に消えます。 ブラウザを閉じたあとも復元したいなら localStorage です。

パスワードも保存していいですか

絶対にだめです。平文で端末に残ります(表10)。 保存する項目を配列で明示する許可制にすれば、そもそも混ざりません。

送信したのに古い内容が復元されます

消す処理が入っていません(表12)。 送信が成功したら消してください。 押した時点で消すと、通信が失敗したときに入力が消えます。

共有パソコンで前の人の入力が出ます

同じ原因です。sessionStorage にしておけば、タブを閉じた時点で消えます(表4)。

フォームの項目を変えたら表示が壊れました

古い形のデータが復元されています。 保存するときにバージョン番号を一緒に入れ、違えば捨ててください(コード)。

まとめ

Storage自体の話は localStorage の使い方と、2026年に気をつけること、 SSRで localStorage is not defined が出る話は localStorage is not defined の直し方、 送信まわりの詰まりどころは Vue 3 で axios の POST が思ったとおりに動かない に書きました。

イベントの発火と保存コストは、2026年10月25日に Chrome 152.0.7977.167 で実際に計測したものです。 モバイルで beforeunload が発火しない件は、MDNの記載であり、実機では確認していません。 測定値は端末と条件で変わります。気になる規模なら、自分の環境で測ってください。