🛡️

Next.js App Router でのフォーム離脱防止

に公開
3

概要

フォームの入力中に離脱すると入力中の内容が失われてしまいます。離脱前に確認ダイヤログを挟むことで意図せぬ入力内容の消失を防ぐことができます。

離脱には以下の 2 種類があります。

  1. ブラウザ操作による離脱: タブを閉じる、更新する、ブラウザナビゲーション(戻る、進む)
  2. Next.js のルーティングによる離脱: Next.js の機能で別ルートに遷移する

このうち 2 は Next.js の router.events という機能でイベントを検知&キャンセルすることになりますが、この機能が App Router から使えなくなりました

この課題についてユーザーから多くのフィードバックがありましたが、明確な解決策がないまま議論はクローズされてしまいました。

https://github.com/vercel/next.js/discussions/41934

以上を踏まえ、主なアプローチをいくつか紹介します。

onNavigate を使う(公式アプローチ)

公式ガイドが追加されました。

https://nextjs.org/docs/app/api-reference/components/link#blocking-navigation

ただし

  • router.push がガードできない
  • フォームから見える<Link>をすべてカスタムリンクに置き換える必要がある
  • 実装コストが高く、ヒューマンエラーで離脱の抜け道ができやすい(うっかり普通のLinkを使ったらそこから離脱できる)

という課題があります。

カスタムフックを使う

Web API のイベントリスナーを使ってリンクのクリックを検知&確認&キャンセルします。なお、nprogress も同様のアプローチでこの問題を解決しているようです。

まずガード用のカスタムフックを作成します。

import { useEffect } from 'react';

export const useNavigationGuard = (isDirty: boolean) => {
  useEffect(() => {
    const handleClick = (event: MouseEvent) => {
      if (
        isDirty &&
        event.target instanceof Element &&
        event.target.closest('a:not([target="_blank"])')
      ) {
        if (!window.confirm('ページを離れても良いですか?')) {
          event.preventDefault();
          event.stopPropagation();
        }
      }
    };

    const handleBeforeUnload = (event: BeforeUnloadEvent) => {
      if (isDirty) {
        event.preventDefault();
        return (event.returnValue = '');
      }
    };

    window.addEventListener('beforeunload', handleBeforeUnload);
    window.addEventListener('click', handleClick, true);

    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
      window.removeEventListener('click', handleClick, true);
    };
  }, [isDirty]);
};

以下のようにカスタムフックを使います。これを差し込んだページではフォームガードが有効になります。確認を挟むべきかどうかのフラグを渡すだけなので、 必ずしも react-hook-form を使う必要はありません。以下は  React Hook Form を使った例です。

export default function FormPage() {
  const { formState: { isDirty } } = useForm();

  useNavigationGuard(isDirty);
  // ...
}

動作デモ

https://dninomiya.com/ja/docs/form

補足

このアプローチでは JS で router.push(back,forward,refresh) した場合の離脱防止ができません。そこも考慮する場合、将来的な router.events の復活を待つか、 router.push の現場に欠かさず離脱防止処理を差し込む必要があります。ただそもそも router.push をナビゲーション用途で使うべきではないので大きな課題ではないはずです。

next-navigation-guard を使う

https://github.com/LayerXcom/next-navigation-guard

router.push 系も防げるので完全にガードしたい場合は最適解といえます。ただ個人的には

  • 本来的には Next.js や Web API での解決が望ましい(待ち)
  • Next.js や history の挙動をチューニングするアプローチは避けたい
    • Next.js や Web API のアップデートとの互換性を気にしたくない
    • ブラックボックスが予期せぬ副作業を生むリスク

という観点で、ライブラリの導入は最終手段にしたい感じです。

そもそものUI設計を見直す

  • 長いフォームはセクション単位で保存可能にする
  • 記事フォーム系は全画面表示にして導線自体隠す

として、そもそもガード不要な状態が理想な気がします。

まとめ

そもそもガード不要なUI設計を心がけつつ、必要な場合はカスタムフックで凌ぐ。完全に防ぎたいなら next-navigation-guard を使う。

ブラウザの離脱ガードに対する補足

  • Safari では beforeunload がブラウザバックに反応しないのでブラウザバックは離脱ガードできません(防ぎ方あれば教えてください)
  • beforeunload の際の確認メッセージはほとんどのモダンブラウザで編集不可能です

Discussion