beforeunloadの確認ダイアログが出ない・効かない5つの原因と直し方(デモ付き)

はじめに

入力フォームからの離脱事故を防ぐために beforeunload イベントを実装した。ところが、ページを閉じても「入力内容が保存されていません」の確認ダイアログが出ない。あるいは、昔の記事のとおりにメッセージを設定したのに、自分の書いた文言ではない別のメッセージが出る。

beforeunload はここ10年で仕様が大きく変わったAPIで、検索して出てくる古い記事のコードや、AIが生成するコードが現行ブラウザでは動かないことが珍しくありません。この記事では「ダイアログが出ない」原因を5つに整理して、1つずつ確認する手順と現行の正しい書き方をまとめます。

まず確認:オリジナルの文言は出せません(全ブラウザで廃止済み)

原因の切り分けに入る前に、いちばん多い誤解を先に片付けます。「設定したメッセージが表示されない」のは不具合ではなく、現行ブラウザの仕様です。かつては beforeunload で自由なメッセージをダイアログに表示できましたが、詐欺サイトが「移動すると罰金が発生します」のような脅し文句に悪用したため、主要ブラウザは2016年前後にカスタム文言のサポートを廃止しました。

現在はどう書いても、ブラウザが用意した固定文言のダイアログしか出ません。ページ側で制御できるのは「出すか出さないか」だけです。

ブラウザ カスタム文言の廃止 現在表示される固定文言(日本語環境)
Chrome/Edge Chrome 51(2016年)で廃止 「サイトを離れますか? 行った変更が保存されない可能性があります。」
Firefox Firefox 44(2016年)で廃止 「このページから移動しますか? 入力したデータは保存されません。」
Safari(macOS) Safari 9.1(2016年)で廃止 「このページから移動してもよろしいですか?」
iOS Safari 同上 そもそもダイアログが出ない場面が多い(原因5参照)

固定文言の細部はブラウザのバージョンで変わることがあります(表は2026年7月時点)。「文言を変えられないか」を探すのは時間の無駄なので、ダイアログはブラウザ任せにして、伝えたい情報がある場合はページ内のUI(保存状態の表示や下書き保存)で伝える設計に切り替えてください。

ダイアログが出ない5つの原因

文言ではなくダイアログ自体が出ない場合、原因は次の5つのどれかです。上から順に確認してください。

  1. preventDefaultreturnValue の書き方が間違っている
  2. ユーザーがページを一度も操作していない(sticky activation)
  3. SPAのルーター遷移では発火しない
  4. リスナーの登録・解除まわりのミス
  5. iOS Safariでは信頼できない

失敗デモ:ダイアログが出ないパターン

確認ダイアログはページ全体の遷移で出るものなので、記事内の枠の中では再現できません。以下のリンクを別タブで開き、フォームに入力してからページ内のリンクを踏んでみてください。PCのブラウザで確認できます(iPhoneでは原因5のとおり出ないことがあります)。

出ないパターン ハンドラーでreturnしているだけ 入力途中でリンクを踏んでも確認ダイアログが出ず、入力内容ごと移動してしまう状態を確認できます。

このデモには beforeunload のハンドラーが登録されていますが、ダイアログは出ません。何が足りないのかを、原因1から順に見ていきます。

原因1:preventDefaultとreturnValueの書き方

最初に疑うのはハンドラーの中身です。古い記事でよく見る「文字列をreturnする」書き方は、addEventListener で登録したハンドラーでは無視されます。

// NG:addEventListenerのハンドラーで文字列をreturnしても無視される
window.addEventListener('beforeunload', (event) => {
  return '入力内容が保存されていません。移動しますか?';
});

現行の正しい書き方は event.preventDefault() です。あわせて event.returnValue も設定しておくと、preventDefault 単独に対応していない古いChrome/Edge(119未満)でも動きます。MDNもこの2つを併記する形を推奨しています。

// OK:現行の標準的な書き方
window.addEventListener('beforeunload', (event) => {
  event.preventDefault();      // 現行仕様でのダイアログ表示指示
  event.returnValue = true;    // Chrome/Edge 118以前向けの互換用
});

補足を2つ。window.onbeforeunload = () => '...' のようにプロパティへ代入して文字列を返す古い書き方は、レガシー互換としてダイアログ自体は出ます(文言は無視されます)。また event.returnValue = ''(空文字)を設定する例も古い記事に多く残っていますが、空文字の扱いはブラウザ間で解釈が割れてきた歴史があるので、これから書くなら上記の形に揃えてください。

確認手順:ハンドラーの中で preventDefault() を呼んでいるかを見る。returnしているだけなら書き換えます。

原因2:ユーザーがページを一度も操作していない

コードが正しくても、ページを開いてから一度もクリックや入力をしていない状態では、ブラウザはダイアログを出しません。これはsticky activation(ユーザー操作の記録)と呼ばれる要件で、「操作していないなら守るべき入力データも存在しないはずだ」という考えに基づく、悪用防止のための現行仕様です。

実装中にハマりやすいのはテストのときです。ページをリロードした直後に、何も触らずそのままタブを閉じて「出ない、壊れた」と判断してしまうパターンで、これは正常な動作です。ユーザーの実利用では入力してから離脱するのでほぼ問題になりませんが、自動リロード後や、別タブで開き直した直後なども操作記録がリセットされる点は覚えておいてください。

確認手順:ページ内を一度クリックするか1文字入力してから、タブを閉じる/リロードする。これで出るなら原因2で、コードは正常です。

原因3:SPAのルーター遷移では発火しない

beforeunload はその名のとおり、ドキュメントが破棄(unload)される直前のイベントです。ReactやVueのSPAでルーターが行う画面遷移は、URLを history.pushState で書き換えてDOMを差し替えているだけで、ドキュメントは破棄されません。だから発火しません。壊れているのではなく、監視対象のイベントがそもそも起きていない状態です。

SPA内の遷移を止めたい場合は、遷移の入り口(リンクのクリックやルーターのフック)で自前の確認を挟みます。バニラJSで書くならこうなります。

// SPA内リンクの遷移前に確認を挟む(バニラJSの例)
document.addEventListener('click', (event) => {
  const link = event.target.closest('a[data-spa-link]');
  if (!link || !isDirty) return;

  const ok = confirm('入力内容が保存されていません。ページを移動しますか?');
  if (!ok) {
    event.preventDefault(); // ルーターに渡さず遷移を中止
  }
});

各ルーターにはこのための公式機能があります。React Routerの useBlocker、Vue Routerの onBeforeRouteLeavebeforeRouteLeave ガード)などです。APIの形はバージョンで変わりやすいので、実装時は使っているルーターの公式ドキュメントを確認してください。

なお、SPAでもタブを閉じる・リロードする・外部サイトへ移動するときはドキュメントが破棄されるので beforeunload が必要です。ルーターのガードと beforeunload は片方では足りず、両方セットで実装します。

確認手順:出ない操作が「アプリ内の画面遷移」かどうかを見る。アプリ内遷移ならルーターのガードを、タブを閉じたときにも出ないなら他の原因を疑います。

原因4:リスナーの登録・解除まわりのミス

「未入力のときは警告しない」を実装するために、多くのコードはdirtyフラグ(変更があったかどうか)で分岐します。この管理にミスがあると、コード自体は正しくてもダイアログが出ません。よくあるのは次の2つです。

dirtyフラグが立っていない

変更検知を change イベントだけで拾っていると、入力途中(フォーカスを外す前)の離脱を検知できません。検知は input イベントで行います。また、JSからプログラム的に値を書き換えた場合は input イベント自体が発火しないため、その経路があるならフラグを直接立てる処理が必要です。

解除のタイミングと参照のミス

送信ボタンを押したときにダイアログを出さないよう、submit時にリスナーを解除(またはフラグをリセット)する設計はよくあります。このとき解除処理が早すぎてバリデーションエラーで送信が中断されたのに解除だけ残ると、以降ダイアログが出なくなります。逆に removeEventListener に登録時と別の関数参照を渡すと解除に失敗し、送信の瞬間にもダイアログが出てしまう逆パターンになります。

let isDirty = false;

// 変更検知はinputイベントで(changeだと入力途中の離脱を拾えない)
form.addEventListener('input', () => {
  isDirty = true;
});

// 送信時は警告を出さない。リスナー解除よりフラグのリセットが安全
form.addEventListener('submit', () => {
  isDirty = false;
});

リスナーの登録・解除を繰り返すよりも、リスナーは1本だけ常設してフラグで分岐するほうが、参照ミスの入り込む余地がなくなります。

確認手順:ハンドラーの先頭に console.log を入れて離脱操作をしてみる。ログ自体が出ないなら登録・解除の問題、ログは出るのにダイアログが出ないならフラグの分岐で早期returnしています。

原因5:iOS Safariでは信頼できない

ここまでの4つを全部直しても、iPhoneでは出ないことがあります。iOS Safariでは、タブを閉じる、ホーム画面に戻る、別のアプリに切り替える、アプリスイッチャーからSafariを終了するといった操作で beforeunload が発火しません。モバイルでは「アプリを切り替えて、あとからブラウザごと終了する」のが普通の使い方なので、離脱の瞬間にイベントを受け取れる保証が構造的にないためです。

結論を先に言うと、iOSで確認ダイアログを確実に出す方法はありません。ここは「離脱を止める」発想から「離脱されても入力内容が消えないようにする」発想に切り替えます。離脱シグナルとしては pagehidevisibilitychange がiOSでも比較的信頼できるので、そこで入力内容を保存します。

function saveDraft() {
  if (!isDirty) return;
  localStorage.setItem('form-draft', JSON.stringify({
    title: titleInput.value,
    memo: memoInput.value,
    savedAt: Date.now()
  }));
}

// iOS Safariでも比較的信頼できる離脱シグナルで保存する
window.addEventListener('pagehide', saveDraft);
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    saveDraft();
  }
});

MDNも、アプリ状態の保存目的なら beforeunload ではなく visibilitychange を使うことを推奨しています。さらに言えば、離脱の瞬間だけに賭けるより、入力のたびに(debounceを挟んで)自動保存しておくのが業務フォームでは最も堅い設計です。

確認手順:PCのブラウザで出るのにiPhoneで出ないなら原因5です。コードを直すのではなく、保存で守る設計を足します。

修正方法のまとめ+修正デモ

5つの原因を踏まえた実装をまとめると、次の形になります。

let isDirty = false;

// 変更検知(inputイベントで拾う)
form.addEventListener('input', () => {
  isDirty = true;
});

// 離脱警告(デスクトップブラウザ向け)
window.addEventListener('beforeunload', (event) => {
  if (!isDirty) return;        // 未入力なら警告しない

  event.preventDefault();      // 現行仕様でのダイアログ表示指示
  event.returnValue = true;    // Chrome/Edge 118以前向けの互換用
});

// 送信時は警告を出さない
form.addEventListener('submit', () => {
  isDirty = false;
});

修正版の動作は以下のリンクを別タブで開いて確認してください。フォームに入力してからページ内のリンクを踏むと、ブラウザの確認ダイアログが出ます。

修正パターン preventDefault+dirtyフラグ 入力途中でリンクを踏むと確認ダイアログが出て、キャンセルでページに留まれる修正版を確認できます。

修正デモで、未入力のままリンクを踏んだ場合と、ページを開いて何も操作せずタブを閉じた場合にダイアログが出ないことも確かめられます。前者はdirtyフラグ、後者は原因2のsticky activationによる動作です。

補足:それでも出ない環境要因

ここまでの5つに当てはまらない場合、コードの外側を疑います。ヘッドレスブラウザや自動テスト環境、キオスクモードではダイアログが抑制されることがあります。ブラウザ拡張が介入しているケースもあります。また、ブラウザのクラッシュやOSによる強制終了ではJS自体が実行されないので、どんな実装でも無力です。beforeunload はあくまで最後の網で、確実に実行される保証はどこにもない、というのがこのAPIとの正しい付き合い方です。

まとめ

beforeunloadの確認ダイアログが出ないときは、まず「カスタム文言は出せない(固定文言のみ)」という現行仕様を押さえたうえで、書き方(preventDefault)、ユーザー操作の有無、SPA遷移かどうか、フラグと解除の管理、iOS Safariの5つを順に確認してください。古い記事やAIの回答は2016年以前の仕様で書かれていることが多いので、動かないときはコードの「書かれた時期」を疑うのが近道です。

そして、確認ダイアログはどこまで整えても「確実には出せない」仕組みです。入力内容を本当に守りたいフォームでは、離脱を止めるより、localStorageへの下書き自動保存で離脱されても復元できるようにするほうが確実です。動くサンプルは下書き自動保存フォーム(localStorage復元)にコピペで使える形でまとめています。

関連するUI事例

離脱警告や入力保護が必要になる代表的なUI事例です。いずれもコピペで動くサンプルを掲載しています。