こんにちは!エンジニアの川野です。
最近はBtoCのサービスを開発していて、ブラウザバック(戻る)で編集中のデータが消えないようにする機能を開発しました。その際の課題と解決策をお話したいと思います。
はじめに
本記事では、Next.jsにおいてユーザがブログなどの投稿画面からブラウザバックするときに確認ダイアログを表示する方法を説明します。
上記の機能は、ユーザの誤操作によって編集中の投稿が消えてしまうのを防ぐことで、UXの向上を目的としています。
実現したいこと
- 投稿画面からブラウザバックするときに「保存されていないデータは削除されますが、よろしいですか?」と表示する
- 前回保存時から投稿に変更があった場合のみ、ダイアログを表示する (未編集時にダイアログが表示されるのは煩わしいため)
- 「保存する」を押して保存された状態であれば、ダイアログを表示しない
環境情報
- Next.js: 11.0.1
- TypeScript: 4.3.5
- Bootstrap: 5.0.2
実装上の課題
上記を実現するにあたり、2つ課題がありました。
Next.jsではbeforeunloadイベントでブラウザバックを検知できない
beforeunloadイベントは、画面遷移が要求されページがアンロードされるときに発動されますが、Next.jsでのページ遷移はURLに対応したコンポーネントに差し替えているだけですので、ページがunloadされません。
したがって、今回はこちらのイベントは使いません。 SPAでなければ、以下のサイトの方法で実現できます。
フォームの2大誤操作「閉じる・戻る」での離脱を減らす確認ダイアログを実装しよう/15か条の10ブラウザバック(戻る)による遷移を止めるのに工夫が必要 ※後述します
実装方法
結論、以下の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
、ブラウザバックで戻るURLが/posts
だとします。
入力した後に「戻る」を押すと、確認ダイアログが出ますが、URLが/posts
に戻ってしまいます。
という具合に「戻る」によるURLの変更を制御できないことがわかります。 これによって、URLと実際にレンダリングされている画面にズレが生じてしまいます。
ダミーの履歴を挿入することでこれを解決します。
posts/
→posts/new
となっていた履歴の間にダミー履歴を挿入して、
posts/
→posts/new
→posts/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しないと関係のないページで確認ダイアログが表示される可能性があります。
また、確認ダイアログを無効化するために保存によってisEdited
がfalse
になったときもclearHandlePopstate
を実行しています。
おわりに
方法1は主にブラウザの機能で実現していて、方法2は主にNext.jsの機能で実現しています。
ブラウザ機能の「戻る」が絡む実装なので、ブラウザ機能に寄せたいという方は方法1を、Next.js上で実装しているのでNext.jsに寄せたいという方は方法2を使うと良いと思います。
やや強引に実装しましたが、そもそも簡単に実装できるブラウザAPIが提供されていればよいですよね。
調べたところ「戻る」が効かないサイトを作ることができてしまうため、UXの観点から提供されていないようです。
ブラウザバックの制御については社内のエンジニアも毎回面倒くさいと嘆いていました。
この記事を読んで、ブラウザバックを制御しようとしている方の嘆きがなくなれば幸いです。