Reactで、未保存の変更があるときにページを離れる前に確認を出す方法

​​React​beforeunload,flushSync,Playwright,React Router,TypeScript,useBlocker

フォームに入力したまま保存していないとき、ページを離れる前に確認を出すと、入力をうっかり捨てずに済みます。ただし、ページを離れる操作は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)に入らないためです。そこで、変更があるあいだだけリスナーを付けるカスタムフックにしました。

ts
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 のリスナーを付けた数と外した数を数えました。メモに「買い物」と入力して、再読み込みした結果です。

text
開いた直後のリスナー: {"added":0,"removed":0}
入力したあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"、リスナー: {"added":1,"removed":0}
再読み込み(ダイアログで「とどまる」): ページはそのまま、ダイアログ: beforeunload message=""
  とどまったあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"
再読み込み(ダイアログで「離れる」): ページが読み込み直された、ダイアログ: beforeunload message=""
  読み込み直したあと: URL=/edit、表示=保存済み、メモ=""

開いた直後はリスナーがなく、入力すると1つ付きました。再読み込みすると beforeunload のダイアログが出て、とどまると入力はそのまま残りました。離れると、入力は消えました。

保存したあとと、タブを閉じる操作も試しました。

text
保存したあと: 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 の定義を省いたものです。

tsx
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 に値を入れます。

入力してから、画面の上の「一覧」のリンクを押した結果です。

text
「一覧」を押したあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"、確認の表示=1、ブラウザのダイアログ=0回
「このページにとどまる」のあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"、確認の表示=0
「変更を捨てて移動する」のあと: URL=/、見出し=メモの一覧、リスナー: {"added":1,"removed":1}

リンクを押しても URL は /edit のままで、アプリの確認が出ました。ブラウザのダイアログは出ていません。「このページにとどまる」を押すと確認が消え、入力は残りました。「変更を捨てて移動する」を押すと一覧に移り、編集画面が外れたので、beforeunload のリスナーも外れました。

この流れを GIF にしました。上の帯に出る操作の回数、カーソル、押した位置の輪、変わった表示のピンクの強調は、撮影用に外から足したものです。

上の帯に何回目の操作かが出る。メモに「牛乳」と入力すると状態の文がピンクで光って「保存していない変更があります」に変わる。「一覧」を押すと確認の枠がピンクで光って出て、「このページにとどまる」を押すと消える。もう一度「一覧」を押して「変更を捨てて移動する」を押すと、見出しが「メモの一覧」に変わる
入力してから「一覧」へ移ろうとしたときの、アプリの確認の出方と、とどまる・移動するの2つの選び方

ブラウザの「戻る」も試しました。一覧から編集画面に移って入力したあと、「戻る」を押した結果です。

text
戻るを押したあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"、確認の表示=1
「このページにとどまる」のあと: URL=/edit、表示=保存していない変更があります、メモ="買い物"

「戻る」でも、移動が止まってアプリの確認が出ました。とどまると、URL は /edit のままでした。

一方、useBlocker のある画面で再読み込みすると、出るのはブラウザのダイアログで、アプリの確認は出ませんでした。

text
再読み込み: ページはそのまま、ブラウザのダイアログ: beforeunload message=""、アプリの確認の表示=0

useBlocker には、真偽値の代わりに関数も渡せます。関数は、いまの場所(currentLocation)、移動先(nextLocation)、移動の種類(historyAction)を受け取ります。同じ画面の中でクエリ文字列だけが変わる移動は止めない、といった使い分けができます。この書き方は試していません。

保存した直後に移動すると、止まってしまう

「保存して一覧へ」のように、保存したあとにすぐ navigate を呼ぶ場面では、注意が必要です。setSaved で保存済みにしても、その場ですぐ isDirty が変わるわけではありません。React のドキュメントの、state をスナップショットとして説明するページにあるとおり、新しい state は次の描画で使われます。

検証では、setSaved のすぐあとに navigate('/') を呼ぶ書き方と、flushSync で反映してから呼ぶ書き方を比べました。

ts
// setSaved のすぐあとに呼ぶ
await saveNote(draft);
setSaved(draft);
navigate('/');
text
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 を呼んだところ、描画の途中で次のエラーが投げられました。

text
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 を呼びます。

​Posted by 86