Reactで、未保存の変更があるときにページを離れる前に確認を出す方法
フォームに入力したまま保存していないとき、ページを離れる前に確認を出すと、入力をうっかり捨てずに済みます。ただし、ページを離れる操作は2種類あり、止め方が違います。再読み込みやタブを閉じる操作はブラウザの beforeunload イベントで、アプリの中のリンクやブラウザの「戻る」による画面移動は React Router の useBlocker で止めます。
筆者は検証環境で、メモを編集する小さな画面を作り、Chromium を Playwright で操作して、どの操作でどちらの確認が出るかを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、react-router 7.18.4、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 153 を Playwright 1.63.0 で操作しました。画面は同じマシンの中の HTTP サーバー(http://127.0.0.1)から配信しています。タブを閉じる操作は、Playwright の page.close({ runBeforeUnload: true }) で試しました。Firefox と Safari での動作、スマートフォンのブラウザ、React Router のフレームワークモードは試していません。
2つの止め方と、それぞれが受け持つ操作
検証環境で、入力したまま保存していない状態にして、いろいろな方法でページを離れようとした結果をまとめます。
| 離れ方 | 止めるもの | 出る確認 |
|---|---|---|
| 再読み込み | beforeunload |
ブラウザのダイアログ |
| タブを閉じる | beforeunload |
ブラウザのダイアログ |
| アプリの中のリンク | useBlocker |
アプリで作った表示 |
| ブラウザの「戻る」(アプリの中の画面へ) | useBlocker |
アプリで作った表示 |
アプリの中の画面移動では、ページは読み込み直されないので、beforeunload は起きません。反対に、useBlocker の説明にあるとおり、useBlocker が止めるのはアプリの中の画面移動だけで、再読み込みやほかのオリジンへの移動は扱いません。そのため、両方を組み合わせます。
再読み込みとタブを閉じる操作は、beforeunload で止める
MDN の beforeunload イベントのページによると、このイベントで event.preventDefault() を呼ぶと、ブラウザが確認のダイアログを出します。古いブラウザ向けに event.returnValue にも値を入れておくのが、MDN の勧める書き方です。
同じページには、リスナーは保存していない変更があるときだけ付け、いらなくなったら外すように、とも書かれています。Firefox では beforeunload のリスナーがあるページは、戻る・進むで使うキャッシュ(bfcache)に入らないためです。そこで、変更があるあいだだけリスナーを付けるカスタムフックにしました。
import { useEffect } from 'react';
// when が true のあいだだけ beforeunload を聞き、
// 再読み込みやタブを閉じる前に、ブラウザの確認ダイアログを出してもらう
export function useBeforeUnloadWarning(when: boolean) {
useEffect(() => {
if (!when) return;
const handler = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = true; // preventDefault() に対応していない古いブラウザ向け
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [when]);
}
when が false に戻ると、後片付けでリスナーが外れます。検証では、beforeunload のリスナーを付けた数と外した数を数えました。メモに「買い物」と入力して、再読み込みした結果です。
開いた直後のリスナー: {"added":0,"removed":0}
入力したあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"、リスナー: {"added":1,"removed":0}
再読み込み(ダイアログで「とどまる」): ページはそのまま、ダイアログ: beforeunload message=""
とどまったあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"
再読み込み(ダイアログで「離れる」): ページが読み込み直された、ダイアログ: beforeunload message=""
読み込み直したあと: URL=/edit、表示=保存済み、メモ=""
開いた直後はリスナーがなく、入力すると1つ付きました。再読み込みすると beforeunload のダイアログが出て、とどまると入力はそのまま残りました。離れると、入力は消えました。
保存したあとと、タブを閉じる操作も試しました。
保存したあと: URL=/edit、表示=保存済み、メモ="買い物"、リスナー: {"added":1,"removed":1}
再読み込み: ページが読み込み直された、ダイアログ: 0回
閉じる操作(ダイアログで「とどまる」): ダイアログ: beforeunload message=""、閉じた=false
保存するとリスナーが外れ、再読み込みしてもダイアログは出ませんでした。タブを閉じる操作でもダイアログが出て、とどまるとタブは閉じませんでした。
ダイアログの文言は変えられません。検証では、Playwright で受け取ったダイアログの message は空でした。MDN によると、表示されるのはブラウザが用意した決まった文言だけで、ページのコードからは変えられません。
beforeunload は、いつも起きるとは限りません。MDN によると、スマートフォンでほかのアプリに切り替えたあと、アプリの一覧からブラウザを閉じた場合などには起きません。MDN は、アプリの状態を自動で保存するきっかけには、より確実に起きる visibilitychange イベントを使うよう勧めています。確認のダイアログだけに頼らず、下書きの保存と組み合わせるのが安全です。
MDN によると、いまのブラウザは、ユーザーが一度もページを操作していないときにはダイアログを出しません(sticky activation と呼ばれる条件です)。この条件は、検証環境では確かめられませんでした。Playwright で動かした Chromium では、一度も操作していないページでもダイアログが出たためです。
アプリの中の画面移動は、useBlocker で止める
React Router の useBlocker に true を渡すと、アプリの中の画面移動が止まり、返ってくる blocker の state が 'blocked' になります。このとき、blocker.proceed() で移動を続けるか、blocker.reset() でとどまるかを選べます。確認の表示は、アプリで自由に作れます。
先ほどのフックと組み合わせた編集画面です。検証用のコードでは、あとの節で比べるために、URL のパラメーターで「保存して一覧へ」の動きを切り替えています。下は、flushSync を使う側だけを残し、saveNote と savedNote の定義を省いたものです。
import { useState } from 'react';
import { flushSync } from 'react-dom';
import { useBlocker, useNavigate } from 'react-router';
import { useBeforeUnloadWarning } from './useBeforeUnloadWarning';
export function EditPage() {
const [saved, setSaved] = useState(savedNote);
const [draft, setDraft] = useState(savedNote);
const isDirty = draft !== saved;
useBeforeUnloadWarning(isDirty); // 再読み込み・タブを閉じる・ほかのサイトへ移る
const blocker = useBlocker(isDirty); // アプリの中の画面移動
const navigate = useNavigate();
const save = async () => {
await saveNote(draft);
setSaved(draft);
};
const saveAndGoHome = async () => {
await saveNote(draft);
flushSync(() => setSaved(draft)); // 画面と useBlocker に「保存済み」を反映してから
navigate('/');
};
return (
<section>
<h1>メモの編集</h1>
<textarea aria-label="メモ" value={draft} onChange={(e) => setDraft(e.target.value)} />
<p>{isDirty ? '保存していない変更があります' : '保存済み'}</p>
<button onClick={save}>保存</button>
<button onClick={saveAndGoHome}>保存して一覧へ</button>
{blocker.state === 'blocked' && (
<div role="alertdialog" aria-labelledby="leave-title">
<p id="leave-title">保存していない変更があります。このページを離れますか?</p>
<button onClick={() => blocker.reset()}>このページにとどまる</button>
<button onClick={() => blocker.proceed()}>変更を捨てて移動する</button>
</div>
)}
</section>
);
}
saveNote は、検証用にサーバーへの保存の代わりをする関数で、100ミリ秒待ってから、モジュールの変数 savedNote に値を入れます。
入力してから、画面の上の「一覧」のリンクを押した結果です。
「一覧」を押したあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"、確認の表示=1、ブラウザのダイアログ=0回
「このページにとどまる」のあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"、確認の表示=0
「変更を捨てて移動する」のあと: URL=/、見出し=メモの一覧、リスナー: {"added":1,"removed":1}
リンクを押しても URL は /edit のままで、アプリの確認が出ました。ブラウザのダイアログは出ていません。「このページにとどまる」を押すと確認が消え、入力は残りました。「変更を捨てて移動する」を押すと一覧に移り、編集画面が外れたので、beforeunload のリスナーも外れました。
この流れを GIF にしました。上の帯に出る操作の回数、カーソル、押した位置の輪、変わった表示のピンクの強調は、撮影用に外から足したものです。

ブラウザの「戻る」も試しました。一覧から編集画面に移って入力したあと、「戻る」を押した結果です。
戻るを押したあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"、確認の表示=1
「このページにとどまる」のあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"
「戻る」でも、移動が止まってアプリの確認が出ました。とどまると、URL は /edit のままでした。
一方、useBlocker のある画面で再読み込みすると、出るのはブラウザのダイアログで、アプリの確認は出ませんでした。
再読み込み: ページはそのまま、ブラウザのダイアログ: beforeunload message=""、アプリの確認の表示=0
useBlocker には、真偽値の代わりに関数も渡せます。関数は、いまの場所(currentLocation)、移動先(nextLocation)、移動の種類(historyAction)を受け取ります。同じ画面の中でクエリ文字列だけが変わる移動は止めない、といった使い分けができます。この書き方は試していません。
保存した直後に移動すると、止まってしまう
「保存して一覧へ」のように、保存したあとにすぐ navigate を呼ぶ場面では、注意が必要です。setSaved で保存済みにしても、その場ですぐ isDirty が変わるわけではありません。React のドキュメントの、state をスナップショットとして説明するページにあるとおり、新しい state は次の描画で使われます。
検証では、setSaved のすぐあとに navigate('/') を呼ぶ書き方と、flushSync で反映してから呼ぶ書き方を比べました。
// setSaved のすぐあとに呼ぶ
await saveNote(draft);
setSaved(draft);
navigate('/');
setSaved のすぐあとに呼ぶ: URL=/edit、見出し=メモの編集、確認の表示=1、表示=保存済み
flushSync で反映してから呼ぶ: URL=/、見出し=メモの一覧、確認の表示=0
すぐに呼ぶ書き方では、表示は「保存済み」なのに、「保存していない変更があります。このページを離れますか?」の確認が出ました。setSaved のあと、まだ再描画もエフェクトも動いていないうちに navigate を呼んでいるためです。React Router のソースを読むと、useBlocker は受け取った値をエフェクトの中でルーターに渡しているので、そのエフェクトが動くまでは前の値(true)で判断します。
flushSync で setSaved を包むと、一覧に移りました。flushSync は、渡した関数の中の更新をすぐに画面へ反映し、場合によっては待っているエフェクトも先に動かします。検証では、navigate を呼ぶ前に、useBlocker の値まで反映されていました。React のドキュメントは、flushSync は性能を落とすことがあるので、ほかに方法がないときに使うように、としています。この例のように、保存してすぐ移動する場面に限って使います。
useBlocker は、データルーターの中でだけ使える
useBlocker は、createBrowserRouter と RouterProvider で作るデータルーターの中でだけ使えます。React Router のモードの選び方のページにある、モードごとに使える API の表でも、useBlocker はフレームワークモードとデータモードだけに印が付いていて、<BrowserRouter> を使う宣言的モードには付いていません。
検証で、<BrowserRouter> の中で useBlocker を呼んだところ、描画の途中で次のエラーが投げられました。
useBlocker must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.
<BrowserRouter> を使っているアプリで同じことをするには、データルーターに移す必要があります。
未保存の変更を守るには、2つの止め方をそろえて使う
再読み込みやタブを閉じる操作は、beforeunload で preventDefault() を呼んで止めます。リスナーは、変更があるあいだだけ付けます。アプリの中のリンクやブラウザの「戻る」は、React Router の useBlocker で止め、proceed() と reset() で、移動するかとどまるかを選ばせます。保存した直後に移動するときは、flushSync で保存済みを反映してから navigate を呼びます。





