RIT Tech Blog

株式会社RITのエンジニアが知見を共有する技術ブログです。

Next.jsでブラウザの「戻る」を検知して確認ダイアログを表示する

こんにちは!エンジニアの川野です。 歓喜に湧いた東京オリンピックが終わり、寂しさを覚えますね。

最近はBtoCのサービスを開発していて、ブラウザの「戻る」で編集中のデータが消えないようにする機能を開発したのですが、その際の課題と解決策をお話したいと思います。

はじめに

本記事では、Next.jsにおいてユーザがブログなどの投稿画面からブラウザバックするときに確認ダイアログを表示する方法を説明していきます。

完成イメージ
完成イメージ

上記の機能は、ユーザの誤操作によって編集中の投稿が消えてしまうのを防ぐことで、UXの向上を目的としています。

実現したいこと

  • 投稿画面からブラウザバックするときに「保存されていないデータは削除されますが、よろしいですか?」と表示する
  • 前回保存時から投稿に変更があった場合のみ、ダイアログを表示する (未編集時にダイアログが表示されるのは煩わしいため)
  • 「保存する」を押して保存された状態であれば、ダイアログを表示しない

環境情報

  • Next.js: 11.0.1
  • TypeScript: 4.3.5
  • Bootstrap: 5.0.2

実装上の課題

上記を実現するにあたり、2つ課題がありました。

  1. Next.jsではbeforeunloadイベントでブラウザバックを検知できない
    beforeunloadイベントは、画面遷移が要求されページがアンロードされるときに発動されますが、Next.jsでのページ遷移はURLに対応したコンポーネントに差し替えているだけですので、ページがunloadされません。
    したがって、今回はこちらのイベントは使いません。 SPAでなければ、以下のサイトの方法で実現できます。
    フォームの2大誤操作「閉じる・戻る」での離脱を減らす確認ダイアログを実装しよう/15か条の10

  2. ブラウザバック(「戻る」)による遷移を止めるのに工夫が必要 ※後述します

実装方法

結論、以下の2つの方法で、実装することができました。

方法1: addEventListenerでpopstateイベントを追加する

以下の引用した説明では分かりづらいのですが、要するにpopstateイベントはブラウザの「戻る」や「進む」を押下した際にもトリガーされます。

popstate イベントは、同じ文書の2つの履歴項目の間で、アクティブな履歴項目が変わるたびにウィンドウに発行されます。
WindowEventHandlers.onpopstate

コード例

pages/posts/new.tsx

import React, { useState, useEffect } from 'react';
import { NextPage } from 'next';

export const NewPost: NextPage = () => {
  const [text, setText] = useState('');
  // 編集中かどうかをstateで管理
  const [isEdited, setIsEdited] = useState(false);

  const handlePopstate = () => {
    const isDiscardedOK = confirm(
      '保存されていないデータは削除されますが、よろしいですか?',
    );
    if (isDiscardedOK) {
      // OKの場合、historyAPIで戻るを実行します。
      window.history.back();
      setIsEdited(false);
    }
    // キャンセルの場合、 ダミー履歴を挿入して「戻る」を1回分吸収できる状態にする
    history.pushState(null, '', null);
  };

  useEffect(() => {
    // 編集中になったとき、現在のページを履歴に挿入し、handlePopstateをイベント登録
    if (isEdited) {
      // ダミー履歴を挿入して「戻る」を1回分吸収できる状態にする
      history.pushState(null, '', null);
      window.addEventListener('popstate', handlePopstate, false);
    }
    // 他のページに影響しないようclear
    return () => {
      window.removeEventListener('popstate', handlePopstate, false);
    };
  }, [isEdited]);

  return (
    <div
      className='d-flex justify-content-center'
      style={{ marginTop: '15vh' }}
    >
      <div>
        <h1>投稿画面</h1>
        <form>
          <textarea
            rows={10}
            cols={60}
            value={text}
            onChange={e => {
              setText(e.target.value);
              setIsEdited(true);
            }}
          ></textarea>
        </form>
        <div className='d-flex justify-content-end'>
          <button
            className='btn btn-primary mt-3 me-3'
            // 保存したときは、編集中フラグをfalseにする
            onClick={() => setIsEdited(false)}
          >
            保存する
          </button>
          <button className='btn btn-primary mt-3'>投稿する</button>
        </div>
      </div>
    </div>
  );
};

export default NewPost;

実際に入力欄に文字を打った後に「戻る」を押してみてください。確認ダイアログが表示されるはずです。 入力前と保存をした後であれば確認ダイアログは表示されません。

捕捉説明
history.pushState(null, '', null);で現在のページと同じ履歴を挿入することができます。パラメータは前から、state(状態オブジェクト), title, url になっており、urlにnullを指定することで現在のページの履歴が挿入されます。参考: History.pushState()

現在のページと同じ履歴、いわばダミーの履歴を挿入する理由は、「戻る」によるURLの変更を制御できないためです。

たとえば、以下のようにコメントアウトしておきます。

    ...
    if (isEdited) {
      // history.pushState(null, '', null);
      window.addEventListener('popstate', handlePopstate, false);
    }
    ...

投稿画面のURLは/posts/newなのですが、戻り先が/postsだとします。

投稿画面のURL
投稿画面のURL

入力した後に「戻る」を押すと、確認ダイアログが出ますが、URLが/postsに戻ってしまいます。

「戻る」を押した後
「戻る」を押した後

このように、「戻る」によるURLの変更を制御できないことがわかります。

そこで、ダミーの履歴を挿入することで
posts/posts/new
となっていた履歴の間にダミー履歴を挿入して、
posts/posts/newposts/new
にしているのです。

この状態であれば「戻る」を一度押しても、posts/newにいる状態を維持できます。

実は編集後に保存を押すと、確認ダイアログは出なくなるのですが、「戻る」を二度押さないと戻ることができません。これは上記のダミー履歴が蓄積されていることによるものです。

ユーザが編集中のデータを失わないという本質的な要件は満たしており、実装コストが見合わなそうだったので、ダミー履歴の蓄積されてしまう事象は今回は解消しませんでした。

方法2: Router.beforePopStateを使う

Router.beforePopStateは、Routerのメソッドで以下の説明にあるようにpopstateイベントを捕捉してイベントを発動するために利用しています。

Router.beforePopState 場合によっては(例えば、カスタムサーバーを使用する場合)、popstate をリッスンして、ルーターが動作する前に何かしたいということがあります。
next/router

コード例

pages/posts/new.tsx

import React, { useState, useEffect } from 'react';
import { NextPage } from 'next';
import { useRouter } from 'next/router';

export const NewPost: NextPage = () => {
  const [text, setText] = useState('');
  // 編集中かどうかをstateで管理
  const [isEdited, setIsEdited] = useState(false);

  const router = useRouter();

  const setHandlePopstate = () => {
    // ダミーの履歴を挿入し、ブラウザバックを1回分吸収する
    history.pushState(null, '', null);
    router.beforePopState(() => {
      const isDiscardedOK = confirm(
        '保存されていないデータは削除されますが、よろしいですか?',
      );
      // OKの場合、historyAPIで戻るを実行します。
      if (isDiscardedOK) {
        setIsEdited(false);
        router.back();
        return true;
      }
      // キャンセルの場合、 ダミー履歴を挿入して「戻る」を1回分吸収できる状態にする
      history.pushState(null, '', null);
      return false;
    });
  };

  // trueをreturnしてページ遷移が正常に動作すうように戻す
  const clearHandlePopstate = () => {
    router.beforePopState(() => true);
  };

  useEffect(() => {
    // 編集中になったとき、現在のページを履歴に挿入し、handlePopstateをイベント登録
    if (isEdited) {
      setHandlePopstate();
    } else {
      clearHandlePopstate();
    }
    // 他のページに影響しないようclear
    return () => clearHandlePopstate();
  }, [isEdited]);

  // 以下は方法1と同じ
  return (
    <div
      className='d-flex justify-content-center'
      style={{ marginTop: '15vh' }}
    >
      <div>
        <h1>投稿画面</h1>
        <form>
          <textarea
            rows={10}
            cols={60}
            value={text}
            onChange={e => {
              setText(e.target.value);
              setIsEdited(true);
            }}
          ></textarea>
        </form>
        <div className='d-flex justify-content-end'>
          <button
            className='btn btn-primary mt-3 me-3'
            // 保存したときは、編集中フラグをfalseにする
            onClick={() => setIsEdited(false)}
          >
            保存する
          </button>
          <button className='btn btn-primary mt-3'>投稿する</button>
        </div>
      </div>
    </div>
  );
};

export default NewPost;

挙動は方法1と同じです。

捕捉説明
useEffect内で、return () => clearHandlePopstate();してあげることが大切です。Routerに紐づけるイベントなのでclearしないと関係のないページで確認ダイアログが表示される可能性があります。

また、確認ダイアログを無効化するために保存によってisEditedfalseになったときもclearHandlePopstateを実行しています。

おわりに

方法1は主にブラウザの機能で実現していて、方法2は主にNext.jsの機能で実現しています。

ブラウザ機能の「戻る」が絡む実装なので、ブラウザ機能に寄せたいという方は方法1を、Next.js上で実装しているのでNext.jsに寄せたいという方は方法2を使うと良いと思います。

やや強引に実装しましたが、ブラウザバックの制御については社内のエンジニアも毎回面倒くさいと嘆いていました。

簡単に実装できるブラウザAPIが提供されていればよいのですが、「戻る」が効かないサイトも作ることができるため、UXの観点から提供されていないのだと推測します。

この記事を読んで、ブラウザバックを制御しようとしている方の時間が節約できれば幸いです。