フォームの入力内容を保存して復元する — 難しいのは「いつ保存するか」と「いつ消すか」
先に結論
入力内容を保存して復元する処理そのものは、10行ほどで書けます。 解説記事もたくさんあります。詰まるのは、そこではありません。
| 素直にやると | 正しくは | |
|---|---|---|
| どこに保存するか | Cookie | Storage。Cookieは毎回サーバーに送られる(詳しく) |
| いつ保存するか | beforeunload で離脱時にまとめて | visibilitychange。beforeunloadは発火しないことがある(詳しく) |
| いつ消すか | 考えていない | 送信が成功したら消す(詳しく) |
MDNが beforeunload についてはっきり書いています。
「このイベントは特にモバイルプラットフォームでは、確実に発生するとは限りません。
例として、モバイルユーザーが別のアプリに切り替え、その後アプリマネージャーからブラウザーを閉じると、
beforeunload イベントは発生しない」
「アプリの状態自動保存をより確実に実行するための代替として、
visibilitychange イベントの使用が推奨される」
— Window: beforeunload イベント(MDN)
「入力途中でアプリを切り替えて、そのままブラウザを閉じる」のは、 スマホでは珍しくありません。そこで保存されないなら、何のための自動保存か分かりません。
Storageそのものの使い方・容量・Safariが7日で消す話は localStorage の使い方と、2026年に気をつけること に書きました。 この記事は「フォームの保存」に絞ります。
Cookieには入れない
2.9倍に膨らむ
Cookieに入れると、中身がそのまま入るわけではありません。 値をURLエンコードする必要があるので、日本語は大きく膨らみます。
現実的なフォーム1件ぶんで測りました。
| 状態 | 大きさ |
|---|---|
| 元のJSON | 365文字 / 889バイト |
| Cookieに入れた状態(URLエンコード後) | 2,539文字 |
| 膨張率 | 約2.9倍 |
測ったのは氏名・メール・電話・郵便番号・住所・本文という、 問い合わせフォームによくある組み合わせです。 特別に長いデータではありません。
Cookie 1つあたりの上限はおおむね4KB程度とされています。 2,539文字という時点で、その枠の半分以上を1つのフォームで使っています。
しかも毎回送られる
Cookieの本質的な問題はサイズではなく、サーバーに送られることです。 保存したいだけなのに、同じドメインへのリクエストすべてに付いていきます。
| 同一オリジンのリクエスト数 | Cookieが2,539バイトなら | |
|---|---|---|
| このサイトの記事ページ(実測) | 2(HTML+CSS) | 約5KB |
| 画像・JS・フォントが並ぶ普通のサイト | 数十 | その数十倍 |
正直に書くと、このサイトは画像もJavaScriptも無いので2リクエストしかありません。 実測値としては小さく出ています。 画像やスクリプトが数十個並ぶ一般的なサイトでは、この数十倍が毎ページ乗ります。
| 保存先 | サーバーに送られるか | フォームの一時保存に |
|---|---|---|
localStorage | 送られない | 向く。タブを閉じても残る |
sessionStorage | 送られない | 向く。タブを閉じると消えるので、消し忘れが起きない |
| Cookie | 毎リクエスト送られる | 向かない |
| サーバーに下書き保存 | — | 別端末から続けたいなら。実装は重い |
「タブを閉じたら消えていい」なら sessionStorage が安全です。
消す処理を書き忘れても、勝手に消えます(詳しく)。
いつ保存するか
普通に遷移すると、全部発火する
ページを離れるときに発火するイベントは複数あります。 普通にリンクをクリックして遷移した場合に、どれが発火するかを測りました。
| 順 | イベント | 発火 |
|---|---|---|
| 1 | beforeunload | した |
| 2 | pagehide | した |
| 3 | visibilitychange(hidden) | した |
| 4 | unload | した |
全部発火します。だから開発中は問題が見えません。
beforeunload で保存するコードを書いて、手元で試して、動いた、で終わってしまいます。
beforeunloadは発火しないことがある
問題が出るのは、開発中には再現しにくい状況です。 MDNが具体例を挙げています。
| 状況 | MDNの記載 |
|---|---|
| 別アプリに切り替えて、アプリマネージャーからブラウザを閉じる | 発火しない |
| モバイル全般 | 「確実に発生するとは限りません」 |
スマホでフォームを書いている途中に電話が来る、という場面を想像してください。 アプリを切り替えて、戻ってこずにブラウザを終了する。 自動保存が一番役に立つべき場面で、保存されていません。
visibilitychange は、画面が隠れた時点で発火します。
アプリを切り替えた瞬間がそれです。その後どうなっても、保存はもう済んでいます。
置くだけで遅くなる副作用もある
beforeunload には、もう1つ代償があります。
「Firefoxでは、beforeunload リスナーが設定されているページは bfcache に保存されず、
性能に悪影響を与える」
— Window: beforeunload イベント(MDN)
bfcache は、戻るボタンでページを瞬時に表示するための仕組みです。
beforeunload を1つ置いただけで、それが効かなくなります。
「保存のために置いたリスナーが、戻る操作を遅くする」という形になります。
| イベント | 確実性 | 副作用 | 使うか |
|---|---|---|---|
visibilitychange(hidden) | 高い | なし | これを使う |
pagehide | 高い | なし | 併用してよい |
beforeunload | モバイルで落ちる | bfcacheが効かなくなる | 保存には使わない |
unload | 低い | bfcacheが効かなくなる | 使わない |
input(都度保存) | 最も確実 | 次章で検証 | 併用する |
beforeunload を完全に捨てるわけではありません。
「保存していない変更があります。本当に離れますか」という確認ダイアログを出す用途には、これしかありません。
保存の処理はそこに置かない、という話です。
「毎回保存は重い」は本当か
「input のたびに保存すると重いから、離脱時にまとめて保存する」という説明をよく見ます。
本当にそうなのか測りました。
まず、1文字打つと何回イベントが出るのか。
| 入力した文字 | input の発火回数 |
|---|---|
| 「こんにちは」(5文字) | 5回 |
1文字につき1回です。では、その1回の保存にどれだけかかるのか。
| 測ったもの | 結果 |
|---|---|
setItem 500回 | 1.9ms |
setItem 1回あたり | 0.004ms |
| 100文字入力して100回保存した場合 | 合計 0.38ms |
「毎回保存は重い」は、少なくともこの規模では成り立ちませんでした。 100文字打って100回保存しても0.38ミリ秒です。 離脱時にまとめる理由を、性能に求めるのは間違いです。
つまり、素直に毎回保存してよいということです。
そのうえで visibilitychange でも保存しておけば、取りこぼしが減ります。
どちらか一方を選ぶ必要はありません。
なお、これは文字列を1つ書くだけの測定です。
保存のたびにフォーム全体を走査して JSON.stringify するなら、その分は乗ります。
気になるなら、そこを測ってください。保存そのものは問題になりません。
何を保存しないか
ここが一番事故につながります。フォームを全部まとめて保存すると、入れてはいけないものが混ざります。
| 項目 | 理由 |
|---|---|
| パスワード | 平文で端末に残る。絶対に保存しない |
| クレジットカード番号・セキュリティコード | 同上。そもそも自前で扱わない |
| マイナンバー・運転免許番号などの識別番号 | 残っていい情報ではない |
| 確認用コード・ワンタイムパスワード | 保存する意味がなく、危険だけが残る |
hidden のトークン類 | 復元すると期限切れで逆に壊れる |
ファイル選択(type="file") | そもそも値を復元できない |
ここで方式の選択が効いてきます。 「保存するものを明示する」のか、「保存しないものを除外する」のかです。 分かれ目は、あとからフォームに項目が増えたときに何が起きるかです。
| 方式 | 項目が増えたとき | 評価 |
|---|---|---|
| 保存するものを明示する(許可制) | 保存されないだけ。害はない | こちらにする |
| 保存しないものを除外する(拒否制) | 新しい項目が自動で保存される。機微な項目だと事故 | 避ける |
許可制にしてください。 「保存されなくて不便」で済むのと、「保存されてはいけないものが残る」のとでは、被害が違います。
いつ消すか
ここを書いていない解説が大半です。 保存する処理だけ入れて消さないと、こうなります。
| 場面 | 起きること |
|---|---|
| 送信が成功したあと | 次に同じフォームを開くと、送信済みの内容が復元される。二重送信の元 |
| 共有のパソコン | 次に使う人の画面に、前の人の入力が出る |
| フォームの項目を変更したあと | 古い形のデータが復元され、画面が壊れる |
| 時間が経ったあと | 半年前の下書きが突然出てくる |
消すタイミングは3つ決めておけば足ります。
| 契機 | やること |
|---|---|
| 送信が成功したら | すぐ消す。送信前ではなく、成功を確認してから |
| 保存から一定期間が過ぎていたら | 復元せずに消す(保存時に時刻を一緒に入れておく) |
| フォームの形が変わっていたら | バージョン番号を一緒に保存し、違えば捨てる |
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();
});
| 行 | 何のため |
|---|---|
FIELDS の配列 | 許可制。パスワード等が混ざらない(表11) |
v: VERSION | フォームの形が変わったら捨てる |
t: Date.now() | 古い下書きを復元しない |
try/catch | 保存に失敗しても入力を止めない |
visibilitychange | beforeunload の代わり(MDNの推奨) |
if (res.ok) clear() | 成功してから消す。失敗時に入力を失わない |
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)。
フォームの項目を変えたら表示が壊れました
古い形のデータが復元されています。 保存するときにバージョン番号を一緒に入れ、違えば捨ててください(コード)。
まとめ
- 保存する処理自体は簡単。判断が要るのは「どこに」「いつ保存して」「いつ消すか」。
- Cookieには入れない。889バイトのJSONが2,539文字になり、しかも毎リクエスト送られる(実測)。
beforeunloadで保存しない。MDNが「モバイルでは確実に発生するとは限りません」と明記している。beforeunloadを置くと bfcache が効かなくなる(Firefox・MDN)。戻る操作が遅くなる。- 保存は
visibilitychange(hidden)。画面が隠れた時点で確定する。 - 通常の遷移では4つとも発火する(実測)。だから手元のテストでは問題が見えない。
- 「毎回保存は重い」は実測で否定された。100文字で0.38ms。素直に毎回保存してよい。
- 保存する項目は許可制。拒否制だと、項目が増えたときに事故になる。
- パスワードとカード番号は保存しない。平文で端末に残る。
- 送信が「成功したら」消す。押した時点で消すと、失敗時に入力を失う。
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の記載であり、実機では確認していません。
測定値は端末と条件で変わります。気になる規模なら、自分の環境で測ってください。