お問い合わせフォームや記事投稿フォームなど、入力に時間がかかる場面でページを誤って閉じたりブラウザバックしてしまうと、それまでの入力内容がすべて消えてしまいます。beforeunload イベントを使えば、ページ離脱前に「本当に離れますか?」という確認をブラウザに表示させることができます。
この記事では基本的な実装から、フォームに変更があった場合のみ警告する精度の高い変更検知、カスタムモーダルで「保存・破棄」を選ばせるパターン、localStorage への下書き自動保存まで段階的に解説します。
beforeunload イベントの基本
beforeunload イベントはページを離れる直前に発火します。ページ遷移・ブラウザバック・タブを閉じる・URL 直打ちなど、あらゆる離脱方法で発火します。
window.addEventListener('beforeunload', (e) => {
e.preventDefault(); // 確認ダイアログを表示させる
e.returnValue = ''; // 一部ブラウザで必要(空文字でよい)
});
以前は
return "ページを離れますか?" でダイアログのテキストをカスタマイズできましたが、フィッシング詐欺への悪用を防ぐため Chrome・Firefox・Safari・Edge はすべてブラウザ固定のメッセージのみ表示します。カスタムテキストを指定しても無視されます。カスタムUI(モーダル)で独自の確認画面を出す方法は後のセクションで解説します。発火する: ブラウザバック・別タブに移動・URL入力・タブを閉じる・ウィンドウを閉じる・ページリロード
発火しない(または不安定): iOS Safari(スワイプバック時)・一部のモバイルブラウザ・
location.replace() や location.href による JS でのリダイレクト変更検知:入力があった場合のみ警告する
ページを開いただけで何も入力していないのに離脱警告が出ると、ユーザーにとって煩わしくなります。フォームに実際に変更があった場合のみ警告するのが正しい UX です。
let isDirty = false; // フォームが変更されたかどうかのフラグ
// フォーム内のすべての入力要素の変更を監視
const form = document.querySelector('#my-form');
form.addEventListener('input', () => { isDirty = true; });
form.addEventListener('change', () => { isDirty = true; }); // select / checkbox / radio 対応
// 離脱時に変更があれば警告
window.addEventListener('beforeunload', (e) => {
if (!isDirty) return; // 変更がなければ何もしない
e.preventDefault();
e.returnValue = '';
});
// フォーム送信時はフラグをリセット(警告なしで遷移させる)
form.addEventListener('submit', () => {
isDirty = false;
});
input: テキスト入力のたびにリアルタイムで発火(テキスト・textarea)change: 値が確定したときに発火(select・checkbox・radio・file)- 両方を監視しないと checkbox や select の変更が検知されません
/**
* フォームの初期状態を保存して「値が変わったか」を正確に判定する
* → フォームを開いて何も変えずに閉じるときは警告しない
* → 変更後に元の値に戻した場合も警告しない
*/
function getFormSnapshot(form) {
const data = {};
new FormData(form).forEach((value, key) => {
if (data[key] !== undefined) {
// 同名キーが複数ある場合(checkbox など)は配列に
data[key] = [].concat(data[key], value);
} else {
data[key] = value;
}
});
return JSON.stringify(data);
}
const form = document.getElementById('my-form');
const snapshot = getFormSnapshot(form); // 初期状態を保存
function isFormDirty() {
return getFormSnapshot(form) !== snapshot;
}
window.addEventListener('beforeunload', (e) => {
if (!isFormDirty()) return;
e.preventDefault();
e.returnValue = '';
});
form.addEventListener('submit', (e) => {
// submit 直前に再チェック(バリデーション失敗で submit が止まる場合もあるため
// submit イベント内でフラグ解除するより、送信完了後に解除が理想)
});
contenteditable・file input など特殊な要素への対応
input / change イベントは通常の入力要素には有効ですが、contenteditable や file は追加対応が必要です。
const form = document.getElementById('my-form');
let isDirty = false;
// 通常の入力
form.addEventListener('input', () => { isDirty = true; });
form.addEventListener('change', () => { isDirty = true; });
// contenteditable 要素(リッチテキストエディタなど)
document.querySelectorAll('[contenteditable="true"]').forEach(el => {
el.addEventListener('input', () => { isDirty = true; });
});
// file input は change で検知できるが、
// ファイルを選択してキャンセルした場合は change が発火しないため
// 「選択済みファイルがあるか」で別途チェックする
const fileInput = document.getElementById('file-input');
fileInput?.addEventListener('change', () => {
isDirty = fileInput.files.length > 0;
});
window.addEventListener('beforeunload', (e) => {
if (!isDirty) return;
e.preventDefault();
e.returnValue = '';
});
カスタムモーダルで「保存・破棄」を選ばせる
ブラウザのデフォルトダイアログはメッセージをカスタマイズできません。「変更を保存しますか?」「破棄して移動」「キャンセル」のような独自 UI が必要な場合は、リンクやフォームの遷移を一時的に横取りしてカスタムモーダルを表示させます。
beforeunload イベントハンドラ内では alert()・confirm()・DOM 操作・モーダル表示がブロックされます。カスタム確認 UI を使いたい場合は beforeunload ではなく、リンクのクリック・ブラウザバック(popstate)を個別にインターセプトするアプローチが必要です。<!-- 離脱確認モーダル -->
<div id="leave-modal" class="leave-modal" hidden role="dialog" aria-modal="true"
aria-labelledby="leave-modal-title">
<div class="leave-modal-inner">
<h2 id="leave-modal-title">入力内容が保存されていません</h2>
<p>このページを離れると、入力した内容が失われます。</p>
<div class="leave-modal-actions">
<button id="leave-cancel" class="btn-secondary">このページにとどまる</button>
<button id="leave-discard" class="btn-danger">入力を破棄して移動</button>
</div>
</div>
</div>
<div id="leave-overlay" class="leave-overlay" hidden></div>
.leave-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.leave-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: #fff;
border-radius: 8px;
padding: 32px;
max-width: 420px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.leave-modal-actions {
display: flex;
gap: 12px;
margin-top: 20px;
justify-content: flex-end;
}
.btn-secondary {
padding: 8px 16px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
cursor: pointer;
}
.btn-danger {
padding: 8px 16px;
background: #dc2626;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
class LeaveConfirm {
#isDirty = false;
#pendingHref = null; // 移動先URL(確認後に移動するため保存)
#modal;
#overlay;
constructor(formSelector) {
const form = document.querySelector(formSelector);
this.#modal = document.getElementById('leave-modal');
this.#overlay = document.getElementById('leave-overlay');
if (!form || !this.#modal) return;
// 変更検知
form.addEventListener('input', () => { this.#isDirty = true; });
form.addEventListener('change', () => { this.#isDirty = true; });
form.addEventListener('submit', () => { this.#isDirty = false; });
// ページ内リンクをインターセプト
document.addEventListener('click', (e) => {
const anchor = e.target.closest('a[href]');
if (!anchor || !this.#isDirty) return;
const href = anchor.getAttribute('href');
// 外部リンク・アンカーリンク (#) は対象外
if (!href || href.startsWith('#')) return;
e.preventDefault();
this.#showModal(href);
});
// ブラウザバック(popstate)をインターセプト
history.pushState(null, '', location.href);
window.addEventListener('popstate', () => {
if (!this.#isDirty) return;
history.pushState(null, '', location.href); // 戻らせない
this.#showModal(null); // 移動先なし(戻る操作のため)
});
// タブを閉じる・リロードなどは beforeunload でカバー
window.addEventListener('beforeunload', (e) => {
if (!this.#isDirty) return;
e.preventDefault();
e.returnValue = '';
});
// モーダルのボタン
document.getElementById('leave-cancel')?.addEventListener('click', () => {
this.#hideModal();
});
document.getElementById('leave-discard')?.addEventListener('click', () => {
this.#isDirty = false;
this.#hideModal();
if (this.#pendingHref) {
location.href = this.#pendingHref;
} else {
history.back();
}
});
// ESC キーでキャンセル
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !this.#modal.hidden) this.#hideModal();
});
}
#showModal(href) {
this.#pendingHref = href;
this.#modal.hidden = false;
this.#overlay.hidden = false;
// モーダル内の最初のボタンにフォーカス(アクセシビリティ)
document.getElementById('leave-cancel')?.focus();
}
#hideModal() {
this.#modal.hidden = true;
this.#overlay.hidden = true;
this.#pendingHref = null;
}
/** 外部から「保存完了」を通知してフラグをリセットする */
markClean() { this.#isDirty = false; }
markDirty() { this.#isDirty = true; }
}
// 使用例
document.addEventListener('DOMContentLoaded', () => {
const guard = new LeaveConfirm('#my-form');
// 保存ボタン押下時に markClean() を呼ぶ
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveFormData(); // API 呼び出しなど
guard.markClean(); // 保存完了 → 以降は警告しない
});
});
localStorage に下書きを自動保存する
離脱を防ぐのと同時に、万が一データが消えてしまった場合のフォールバックとして localStorage への下書き自動保存は非常に有効です。
const DRAFT_KEY = 'form-draft-contact'; // localStorage のキー
const form = document.getElementById('my-form');
/** フォームデータを localStorage に保存 */
function saveDraft() {
const data = {};
new FormData(form).forEach((value, key) => { data[key] = value; });
localStorage.setItem(DRAFT_KEY, JSON.stringify(data));
console.log('[下書き保存]', new Date().toLocaleTimeString());
}
/** localStorage から下書きを復元 */
function restoreDraft() {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
const data = JSON.parse(raw);
Object.entries(data).forEach(([name, value]) => {
const el = form.querySelector(`[name="${name}"]`);
if (!el) return;
if (el.type === 'checkbox' || el.type === 'radio') {
el.checked = (el.value === value);
} else {
el.value = value;
}
});
console.log('[下書き復元] 前回の入力内容を復元しました');
}
/** 下書きを削除(送信完了後に呼ぶ)*/
function clearDraft() {
localStorage.removeItem(DRAFT_KEY);
}
// ── 初期化 ──────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
restoreDraft(); // ページ表示時に復元
// 30秒ごとに自動保存
setInterval(saveDraft, 30_000);
// input のたびにも保存(デバウンス)
let debounceTimer;
form.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(saveDraft, 1000); // 1秒後に保存
});
// 送信完了後に下書き削除
form.addEventListener('submit', () => {
clearDraft();
});
});
input イベントは1文字ごとに発火するため、そのまま localStorage.setItem() を呼ぶと高頻度の書き込みが発生します。setTimeout のデバウンスを使い、「最後の入力から1秒後に保存」と間引くことで、パフォーマンスへの影響を抑えられます。完成形:FormDirtyGuard クラス
beforeunload による離脱警告・変更検知・localStorage 下書き保存をひとつにまとめた汎用クラスです。
class FormDirtyGuard {
#isDirty = false;
#form;
#draftKey;
#debounceTimer = null;
#unloadHandler;
/**
* @param {string|HTMLFormElement} form - 対象フォーム
* @param {object} opts
* @param {string} opts.draftKey - localStorage キー(省略で下書き保存なし)
* @param {number} opts.autoSaveInterval - 自動保存間隔ms(デフォルト: 30000)
* @param {boolean} opts.restoreOnLoad - ページロード時に下書きを復元するか
*/
constructor(form, opts = {}) {
this.#form = typeof form === 'string' ? document.querySelector(form) : form;
this.#draftKey = opts.draftKey ?? null;
const { autoSaveInterval = 30_000, restoreOnLoad = true } = opts;
if (!this.#form) throw new Error('FormDirtyGuard: form not found');
// 下書き復元
if (this.#draftKey && restoreOnLoad) this.#restore();
// 変更検知
this.#form.addEventListener('input', () => this.#onDirty());
this.#form.addEventListener('change', () => this.#onDirty());
this.#form.addEventListener('submit', () => this.markClean());
// beforeunload
this.#unloadHandler = (e) => {
if (!this.#isDirty) return;
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', this.#unloadHandler);
// 自動保存
if (this.#draftKey) {
setInterval(() => { if (this.#isDirty) this.#save(); }, autoSaveInterval);
}
}
#onDirty() {
this.#isDirty = true;
if (!this.#draftKey) return;
clearTimeout(this.#debounceTimer);
this.#debounceTimer = setTimeout(() => this.#save(), 1000);
}
#save() {
const data = {};
new FormData(this.#form).forEach((v, k) => { data[k] = v; });
localStorage.setItem(this.#draftKey, JSON.stringify(data));
}
#restore() {
const raw = localStorage.getItem(this.#draftKey);
if (!raw) return;
try {
const data = JSON.parse(raw);
Object.entries(data).forEach(([name, value]) => {
const el = this.#form.querySelector(`[name="${name}"]`);
if (!el) return;
if (el.type === 'checkbox' || el.type === 'radio') {
el.checked = el.value === value;
} else {
el.value = value;
}
});
} catch { /* JSON.parse 失敗時は無視 */ }
}
/** 保存完了などで「変更なし」状態に戻す */
markClean() {
this.#isDirty = false;
if (this.#draftKey) localStorage.removeItem(this.#draftKey);
}
/** 外部から「変更あり」にする */
markDirty() { this.#isDirty = true; }
get dirty() { return this.#isDirty; }
/** イベントリスナーをすべて解除 */
destroy() {
window.removeEventListener('beforeunload', this.#unloadHandler);
clearTimeout(this.#debounceTimer);
}
}
// ── 使用例 ───────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const guard = new FormDirtyGuard('#contact-form', {
draftKey: 'draft-contact', // localStorage に保存
autoSaveInterval: 30_000, // 30秒ごとに自動保存
restoreOnLoad: true, // ページロード時に復元
});
// 保存ボタン
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveToServer(); // API 保存処理
guard.markClean(); // 保存完了
});
});
よくある質問(FAQ)
beforeunload が発火しないことがあります。これはブラウザの仕様上の制限であり、JavaScript で回避することはできません。iOS ユーザーへのデータ消失対策としては、beforeunload よりも localStorage への下書き自動保存が確実です。ページを再度開いたときに下書きを復元する処理を入れておくと、たとえ警告が出なくてもデータを守れます。event.returnValue や return で指定しても無視されます。カスタムメッセージを見せたい場合は、この記事で紹介したカスタムモーダルアプローチを使ってください。ただしモーダルは「タブを閉じる」「リロード」には対応できないため、beforeunload と組み合わせて使います。useEffect の中で beforeunload リスナーを登録し、クリーンアップ関数で removeEventListener します。Vue 3 では onMounted / onUnmounted で同様に実装します。React Router の useBlocker(v6.3+)や Vue Router の beforeRouteLeave ガードを使うと、SPA 内のルート遷移にも対応できます。form.addEventListener('submit', () => { isDirty = false; }) でフォーム送信時にフラグをリセットするのが基本対処です。ただし、送信後にサーバーサイドでバリデーションエラーが返ってページがリロードされる場合、submit イベントと beforeunload の両方が発火する可能性があります。この場合は送信完了後(Ajax の場合は success コールバック内)で isDirty = false にするほうが確実です。まとめ
| 目的 | 手法 |
|---|---|
| 離脱前にブラウザ標準の確認ダイアログを表示 | beforeunload + e.preventDefault(); e.returnValue = ""; |
| 変更があった場合のみ警告する | isDirty フラグ + input/change イベント |
| フォーム送信時は警告スキップ | submit イベントで isDirty = false |
| カスタムメッセージの確認UI | リンクインターセプト + カスタムモーダル(beforeunload 内では不可) |
| データ消失の最終対策 | localStorage への下書き自動保存(input のデバウンス + setInterval) |
| iOS Safari 対応 | beforeunload は不安定 → localStorage 下書きで補完 |
ブラウザバック時に特定ページに戻れないようにする方法はブラウザの戻るボタンを無効化する方法を、より詳しい beforeunload の jQuery 実装や自動保存パターンはページ離脱時にアラートを表示する完全ガイドも参照してください。