【JavaScript】フォーム離脱時にアラートを表示する方法|beforeunload・変更検知・カスタムモーダル・下書き自動保存・FormDirtyGuardクラスまで解説

お問い合わせフォームや記事投稿フォームなど、入力に時間がかかる場面でページを誤って閉じたりブラウザバックしてしまうと、それまでの入力内容がすべて消えてしまいます。beforeunload イベントを使えば、ページ離脱前に「本当に離れますか?」という確認をブラウザに表示させることができます。

この記事では基本的な実装から、フォームに変更があった場合のみ警告する精度の高い変更検知、カスタムモーダルで「保存・破棄」を選ばせるパターン、localStorage への下書き自動保存まで段階的に解説します。

スポンサーリンク

beforeunload イベントの基本

beforeunload イベントはページを離れる直前に発火します。ページ遷移・ブラウザバック・タブを閉じる・URL 直打ちなど、あらゆる離脱方法で発火します。

beforeunload の最小実装
window.addEventListener('beforeunload', (e) => {
  e.preventDefault();  // 確認ダイアログを表示させる
  e.returnValue = '';  // 一部ブラウザで必要(空文字でよい)
});
カスタムメッセージは 2024 年現在すべての主要ブラウザで無視されます:
以前は return "ページを離れますか?" でダイアログのテキストをカスタマイズできましたが、フィッシング詐欺への悪用を防ぐため Chrome・Firefox・Safari・Edge はすべてブラウザ固定のメッセージのみ表示します。カスタムテキストを指定しても無視されます。カスタムUI(モーダル)で独自の確認画面を出す方法は後のセクションで解説します。
発火するケース・しないケース:
発火する: ブラウザバック・別タブに移動・URL入力・タブを閉じる・ウィンドウを閉じる・ページリロード
発火しない(または不安定): iOS Safari(スワイプバック時)・一部のモバイルブラウザ・location.replace()location.href による JS でのリダイレクト

変更検知:入力があった場合のみ警告する

ページを開いただけで何も入力していないのに離脱警告が出ると、ユーザーにとって煩わしくなります。フォームに実際に変更があった場合のみ警告するのが正しい UX です。

dirty フラグによる変更検知
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 と change の両方を監視する理由:

  • 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 イベントは通常の入力要素には有効ですが、contenteditablefile は追加対応が必要です。

contenteditable と file input の変更検知
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 でカスタムモーダルを表示できない理由:
beforeunload イベントハンドラ内では alert()confirm()・DOM 操作・モーダル表示がブロックされます。カスタム確認 UI を使いたい場合は beforeunload ではなく、リンクのクリック・ブラウザバック(popstate)を個別にインターセプトするアプローチが必要です。
カスタム確認モーダルの HTML
<!-- 離脱確認モーダル -->
<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 への下書き自動保存は非常に有効です。

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();
  });
});
デバウンス(debounce)で保存の頻度を制限する:
input イベントは1文字ごとに発火するため、そのまま localStorage.setItem() を呼ぶと高頻度の書き込みが発生します。setTimeout のデバウンスを使い、「最後の入力から1秒後に保存」と間引くことで、パフォーマンスへの影響を抑えられます。

完成形:FormDirtyGuard クラス

beforeunload による離脱警告・変更検知・localStorage 下書き保存をひとつにまとめた汎用クラスです。

FormDirtyGuard クラス
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)

QiOS Safari で beforeunload が効きません。
AiOS Safari はスワイプバックなど一部のナビゲーション方法で beforeunload が発火しないことがあります。これはブラウザの仕様上の制限であり、JavaScript で回避することはできません。iOS ユーザーへのデータ消失対策としては、beforeunload よりも localStorage への下書き自動保存が確実です。ページを再度開いたときに下書きを復元する処理を入れておくと、たとえ警告が出なくてもデータを守れます。
Q「このページを離れますか?」ダイアログのテキストを変えたい。
A現在の主要ブラウザ(Chrome・Firefox・Safari・Edge)はすべてブラウザ固定のメッセージのみ表示します。カスタムテキストを event.returnValuereturn で指定しても無視されます。カスタムメッセージを見せたい場合は、この記事で紹介したカスタムモーダルアプローチを使ってください。ただしモーダルは「タブを閉じる」「リロード」には対応できないため、beforeunload と組み合わせて使います。
QReact / Vue でも同じ実装が使えますか?
A同じ仕組みをフレームワーク側に合わせて実装します。React では useEffect の中で beforeunload リスナーを登録し、クリーンアップ関数で removeEventListener します。Vue 3 では onMounted / onUnmounted で同様に実装します。React Router の useBlocker(v6.3+)や Vue Router の beforeRouteLeave ガードを使うと、SPA 内のルート遷移にも対応できます。
Qフォームを送信してリダイレクトした後も警告が出てしまいます。
Aform.addEventListener('submit', () => { isDirty = false; }) でフォーム送信時にフラグをリセットするのが基本対処です。ただし、送信後にサーバーサイドでバリデーションエラーが返ってページがリロードされる場合、submit イベントと beforeunload の両方が発火する可能性があります。この場合は送信完了後(Ajax の場合は success コールバック内)で isDirty = false にするほうが確実です。
QlocalStorage に下書きを保存するときのセキュリティ上の注意点は?
AlocalStorage はオリジン(ドメイン)単位で隔離されており、他サイトからは読めません。ただし、パスワード・クレジットカード番号・個人情報などの機密データは localStorage に保存しないでください。localStorage は XSS で盗まれる可能性があります。機密フィールドを含むフォームでは、下書き保存の対象フィールドを名前で明示的に絞り込むか、下書き保存機能自体を使わないようにしてください。

まとめ

目的 手法
離脱前にブラウザ標準の確認ダイアログを表示 beforeunload + e.preventDefault(); e.returnValue = "";
変更があった場合のみ警告する isDirty フラグ + input/change イベント
フォーム送信時は警告スキップ submit イベントで isDirty = false
カスタムメッセージの確認UI リンクインターセプト + カスタムモーダル(beforeunload 内では不可)
データ消失の最終対策 localStorage への下書き自動保存(input のデバウンス + setInterval)
iOS Safari 対応 beforeunload は不安定 → localStorage 下書きで補完

ブラウザバック時に特定ページに戻れないようにする方法はブラウザの戻るボタンを無効化する方法を、より詳しい beforeunload の jQuery 実装や自動保存パターンはページ離脱時にアラートを表示する完全ガイドも参照してください。