Documentation Next.js

はじめに

Webアプリケーションでフォームを扱う際、ユーザーが入力途中で誤ってページを離れてしまうと、入力データが失われてしまいます。これは特に、長いフォームや重要な情報を入力している場合に深刻な問題となります。

この記事では、Next.jsを使用してフォーム入力中の離脱を防止する方法を詳しく解説します。beforeunloadイベント、Next.jsのルーターイベント、そしてカスタムフックを組み合わせた実践的な実装方法を紹介します。

この記事で学べること

  • beforeunloadイベントによるブラウザ離脱防止
  • Next.js Pages Routerでのルート変更時のガード
  • Next.js App Routerでの離脱防止の実装
  • 再利用可能なカスタムフックの作成
  • UIライブラリを使った確認モーダルの実装

離脱防止が必要なシーン

フォーム離脱防止は、以下のようなケースで特に重要です。

シーン具体例
長文入力ブログ記事の執筆、問い合わせフォーム
重要な情報決済情報、会員登録、本人確認
複数ステップウィザード形式のフォーム、アンケート
時間のかかる入力履歴書、申請書類

beforeunloadイベントの基本

beforeunloadとは

beforeunloadイベントは、ユーザーがページを離れようとしたときに発火するブラウザネイティブのイベントです。このイベントを使うことで、以下の操作時に確認ダイアログを表示できます。

  • ブラウザのタブを閉じる
  • ブラウザを閉じる
  • ページをリロードする
  • URLを直接変更する

基本的な実装

import { useEffect } from 'react';

/**
 * beforeunloadイベントを使った基本的な離脱防止
 * @param isDirty - フォームに未保存の変更があるかどうか
 */
const useBeforeUnload = (isDirty: boolean) => {
  useEffect(() => {
    // isDirtyがfalseの場合は何もしない
    if (!isDirty) return;

    const handleBeforeUnload = (event: BeforeUnloadEvent) => {
      // 離脱を防止するための処理
      event.preventDefault();
      // Chrome等の一部ブラウザではreturnValueの設定が必要
      // ※カスタムメッセージは現代のブラウザでは表示されない
      event.returnValue = '';
    };

    // イベントリスナーを登録
    window.addEventListener('beforeunload', handleBeforeUnload);

    // クリーンアップ: コンポーネントのアンマウント時にリスナーを削除
    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
    };
  }, [isDirty]);
};

export default useBeforeUnload;

注意点

beforeunloadイベントには以下の制限があります。

  1. カスタムメッセージは表示されない: セキュリティ上の理由から、最新のブラウザではカスタムメッセージが無視され、ブラウザ標準のメッセージが表示されます
  2. SPAの内部遷移には効かない: Next.jsのクライアントサイドナビゲーションでは発火しません
  3. ユーザー体験への影響: 過度な使用はユーザーを苛立たせる可能性があります

Next.js Pages Routerでの実装

Pages Routerを使用している場合、router.eventsを使ってルート変更を監視できます。

ルート変更時のガード

import { useRouter } from 'next/router';
import { useEffect, useCallback } from 'react';

/**
 * Pages Router用のフォームガードフック
 * @param isDirty - フォームに未保存の変更があるかどうか
 * @param message - 確認ダイアログに表示するメッセージ
 */
const useFormGuard = (
  isDirty: boolean,
  message: string = '入力内容が保存されていません。ページを離れますか?'
) => {
  const router = useRouter();

  // ルート変更時のハンドラー
  const handleRouteChange = useCallback(
    (url: string) => {
      // isDirtyがtrueの場合のみ確認ダイアログを表示
      if (isDirty && !window.confirm(message)) {
        // ユーザーがキャンセルした場合、ルート変更を中止
        router.events.emit('routeChangeError');
        // エラーをスローして遷移を中断
        // この文字列は内部的に使用され、エラーハンドラーで識別可能
        throw 'routeChange aborted by user';
      }
    },
    [isDirty, message, router.events]
  );

  useEffect(() => {
    // ルート変更開始時のイベントを監視
    router.events.on('routeChangeStart', handleRouteChange);

    // クリーンアップ
    return () => {
      router.events.off('routeChangeStart', handleRouteChange);
    };
  }, [router.events, handleRouteChange]);
};

export default useFormGuard;

完全な離脱防止フック(Pages Router)

beforeunloadとルーターイベントを組み合わせた完全なフックを作成します。

import { useRouter } from 'next/router';
import { useEffect, useCallback } from 'react';

interface UseFormGuardOptions {
  /** フォームに未保存の変更があるかどうか */
  isDirty: boolean;
  /** 確認ダイアログのメッセージ */
  message?: string;
  /** beforeunloadイベントを有効にするか */
  enableBeforeUnload?: boolean;
}

/**
 * フォーム離脱防止の完全なフック(Pages Router用)
 */
const useFormGuardComplete = ({
  isDirty,
  message = '入力内容が保存されていません。ページを離れますか?',
  enableBeforeUnload = true,
}: UseFormGuardOptions) => {
  const router = useRouter();

  // beforeunloadイベントのハンドラー
  useEffect(() => {
    if (!isDirty || !enableBeforeUnload) return;

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

    window.addEventListener('beforeunload', handleBeforeUnload);

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

  // ルート変更のハンドラー
  const handleRouteChange = useCallback(
    (url: string) => {
      if (isDirty && !window.confirm(message)) {
        router.events.emit('routeChangeError');
        throw 'routeChange aborted by user';
      }
    },
    [isDirty, message, router.events]
  );

  useEffect(() => {
    router.events.on('routeChangeStart', handleRouteChange);

    return () => {
      router.events.off('routeChangeStart', handleRouteChange);
    };
  }, [router.events, handleRouteChange]);
};

export default useFormGuardComplete;

Next.js App Routerでの実装

App Router(Next.js 13以降)では、router.eventsが使用できないため、異なるアプローチが必要です。

next/navigationを使用した実装

'use client';

import { useEffect, useCallback } from 'react';
import { useRouter, usePathname } from 'next/navigation';

interface UseFormGuardAppRouterOptions {
  /** フォームに未保存の変更があるかどうか */
  isDirty: boolean;
  /** 確認ダイアログのメッセージ */
  message?: string;
}

/**
 * フォーム離脱防止フック(App Router用)
 *
 * 注意: App Routerでは router.events が使用できないため、
 * beforeunloadイベントとカスタムナビゲーション確認を組み合わせます
 */
const useFormGuardAppRouter = ({
  isDirty,
  message = '入力内容が保存されていません。ページを離れますか?',
}: UseFormGuardAppRouterOptions) => {
  const router = useRouter();
  const pathname = usePathname();

  // beforeunloadイベントのハンドラー
  useEffect(() => {
    if (!isDirty) return;

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

    window.addEventListener('beforeunload', handleBeforeUnload);

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

  // popstate(ブラウザの戻る/進むボタン)のハンドラー
  useEffect(() => {
    if (!isDirty) return;

    const handlePopState = (event: PopStateEvent) => {
      if (!window.confirm(message)) {
        // 履歴を元に戻す
        window.history.pushState(null, '', pathname);
      }
    };

    // 現在の状態を履歴に追加(戻るボタン対策)
    window.history.pushState(null, '', pathname);
    window.addEventListener('popstate', handlePopState);

    return () => {
      window.removeEventListener('popstate', handlePopState);
    };
  }, [isDirty, message, pathname]);

  /**
   * 安全なナビゲーション関数
   * この関数を使ってページ遷移を行うことで、確認ダイアログを表示できます
   */
  const safeNavigate = useCallback(
    (href: string) => {
      if (isDirty) {
        if (window.confirm(message)) {
          router.push(href);
        }
      } else {
        router.push(href);
      }
    },
    [isDirty, message, router]
  );

  return { safeNavigate };
};

export default useFormGuardAppRouter;

実践的なフォームコンポーネントの例

基本的なフォームでの使用例

'use client';

import { useState, FormEvent } from 'react';
import useFormGuardAppRouter from '@/hooks/useFormGuardAppRouter';

interface FormData {
  title: string;
  content: string;
}

/**
 * 離脱防止機能付きのフォームコンポーネント
 */
const ProtectedForm = () => {
  // フォームの初期値
  const [formData, setFormData] = useState<FormData>({
    title: '',
    content: '',
  });

  // フォームが変更されたかどうかを追跡
  const [isDirty, setIsDirty] = useState(false);

  // 送信中かどうか
  const [isSubmitting, setIsSubmitting] = useState(false);

  // 離脱防止フックを使用
  const { safeNavigate } = useFormGuardAppRouter({
    isDirty,
    message: '記事の内容が保存されていません。このページを離れますか?',
  });

  // 入力値の変更ハンドラー
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
    // 入力があったらdirtyフラグを立てる
    setIsDirty(true);
  };

  // フォーム送信ハンドラー
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      // APIへの送信処理(例)
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });

      if (response.ok) {
        // 送信成功したらdirtyフラグをリセット
        setIsDirty(false);
        // 成功ページへ遷移
        safeNavigate('/success');
      }
    } catch (error) {
      console.error('送信エラー:', error);
    } finally {
      setIsSubmitting(false);
    }
  };

  // 下書き保存ハンドラー
  const handleSaveDraft = async () => {
    try {
      await fetch('/api/drafts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });
      // 下書き保存後はdirtyフラグをリセット
      setIsDirty(false);
      alert('下書きを保存しました');
    } catch (error) {
      console.error('下書き保存エラー:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="title" className="block text-sm font-medium">
          タイトル
        </label>
        <input
          type="text"
          id="title"
          name="title"
          value={formData.title}
          onChange={handleChange}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
          required
        />
      </div>

      <div>
        <label htmlFor="content" className="block text-sm font-medium">
          本文
        </label>
        <textarea
          id="content"
          name="content"
          value={formData.content}
          onChange={handleChange}
          rows={10}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
          required
        />
      </div>

      {/* 未保存の変更がある場合に警告を表示 */}
      {isDirty && (
        <p className="text-amber-600 text-sm">
          未保存の変更があります
        </p>
      )}

      <div className="flex gap-4">
        <button
          type="button"
          onClick={handleSaveDraft}
          className="px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300"
        >
          下書き保存
        </button>
        <button
          type="submit"
          disabled={isSubmitting}
          className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
        >
          {isSubmitting ? '送信中...' : '公開する'}
        </button>
      </div>
    </form>
  );
};

export default ProtectedForm;

React Hook Formとの統合

React Hook Formを使用している場合は、formState.isDirtyを活用できます。

'use client';

import { useForm } from 'react-hook-form';
import useFormGuardAppRouter from '@/hooks/useFormGuardAppRouter';

interface FormValues {
  email: string;
  name: string;
  message: string;
}

/**
 * React Hook Formと離脱防止を統合したコンポーネント
 */
const ContactForm = () => {
  const {
    register,
    handleSubmit,
    formState: { isDirty, isSubmitting, errors },
    reset,
  } = useForm<FormValues>({
    defaultValues: {
      email: '',
      name: '',
      message: '',
    },
  });

  // React Hook FormのisDirtyを使用
  useFormGuardAppRouter({
    isDirty,
    message: 'フォームの入力内容が失われます。よろしいですか?',
  });

  const onSubmit = async (data: FormValues) => {
    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });

      if (response.ok) {
        // フォームをリセット(isDirtyがfalseになる)
        reset();
        alert('送信が完了しました');
      }
    } catch (error) {
      console.error('送信エラー:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="name">お名前</label>
        <input
          {...register('name', { required: 'お名前は必須です' })}
          className="block w-full border rounded-md p-2"
        />
        {errors.name && (
          <span className="text-red-500 text-sm">{errors.name.message}</span>
        )}
      </div>

      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          type="email"
          {...register('email', {
            required: 'メールアドレスは必須です',
            pattern: {
              value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
              message: '有効なメールアドレスを入力してください',
            },
          })}
          className="block w-full border rounded-md p-2"
        />
        {errors.email && (
          <span className="text-red-500 text-sm">{errors.email.message}</span>
        )}
      </div>

      <div>
        <label htmlFor="message">メッセージ</label>
        <textarea
          {...register('message', { required: 'メッセージは必須です' })}
          rows={5}
          className="block w-full border rounded-md p-2"
        />
        {errors.message && (
          <span className="text-red-500 text-sm">{errors.message.message}</span>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="px-6 py-2 bg-blue-600 text-white rounded-md disabled:opacity-50"
      >
        {isSubmitting ? '送信中...' : '送信'}
      </button>
    </form>
  );
};

export default ContactForm;

カスタム確認モーダルの実装

ブラウザ標準のwindow.confirmではなく、UIライブラリを使ったカスタムモーダルを実装することで、より良いユーザー体験を提供できます。

Headless UIを使った確認モーダル

'use client';

import { Fragment, useState, useCallback, createContext, useContext } from 'react';
import { Dialog, Transition } from '@headlessui/react';

interface ConfirmDialogContextType {
  confirm: (message: string) => Promise<boolean>;
}

const ConfirmDialogContext = createContext<ConfirmDialogContextType | null>(null);

/**
 * 確認ダイアログのプロバイダー
 */
export const ConfirmDialogProvider = ({ children }: { children: React.ReactNode }) => {
  const [isOpen, setIsOpen] = useState(false);
  const [message, setMessage] = useState('');
  const [resolveRef, setResolveRef] = useState<((value: boolean) => void) | null>(null);

  const confirm = useCallback((msg: string): Promise<boolean> => {
    setMessage(msg);
    setIsOpen(true);
    return new Promise((resolve) => {
      setResolveRef(() => resolve);
    });
  }, []);

  const handleConfirm = () => {
    setIsOpen(false);
    resolveRef?.(true);
  };

  const handleCancel = () => {
    setIsOpen(false);
    resolveRef?.(false);
  };

  return (
    <ConfirmDialogContext.Provider value={{ confirm }}>
      {children}

      <Transition appear show={isOpen} as={Fragment}>
        <Dialog as="div" className="relative z-50" onClose={handleCancel}>
          <Transition.Child
            as={Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0"
            enterTo="opacity-100"
            leave="ease-in duration-200"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
          >
            <div className="fixed inset-0 bg-black bg-opacity-25" />
          </Transition.Child>

          <div className="fixed inset-0 overflow-y-auto">
            <div className="flex min-h-full items-center justify-center p-4">
              <Transition.Child
                as={Fragment}
                enter="ease-out duration-300"
                enterFrom="opacity-0 scale-95"
                enterTo="opacity-100 scale-100"
                leave="ease-in duration-200"
                leaveFrom="opacity-100 scale-100"
                leaveTo="opacity-0 scale-95"
              >
                <Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 shadow-xl transition-all">
                  <Dialog.Title className="text-lg font-medium leading-6 text-gray-900">
                    確認
                  </Dialog.Title>
                  <div className="mt-2">
                    <p className="text-sm text-gray-500">{message}</p>
                  </div>

                  <div className="mt-4 flex justify-end gap-2">
                    <button
                      type="button"
                      className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
                      onClick={handleCancel}
                    >
                      キャンセル
                    </button>
                    <button
                      type="button"
                      className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
                      onClick={handleConfirm}
                    >
                      離脱する
                    </button>
                  </div>
                </Dialog.Panel>
              </Transition.Child>
            </div>
          </div>
        </Dialog>
      </Transition>
    </ConfirmDialogContext.Provider>
  );
};

/**
 * 確認ダイアログを使用するためのフック
 */
export const useConfirmDialog = () => {
  const context = useContext(ConfirmDialogContext);
  if (!context) {
    throw new Error('useConfirmDialog must be used within ConfirmDialogProvider');
  }
  return context;
};

まとめ

Next.jsでのフォーム入力中の離脱防止は、以下の要素を組み合わせることで実現できます。

手法対応する離脱パターン
beforeunloadイベントタブを閉じる、リロード、URL直接変更
router.events(Pages Router)Next.js内部のページ遷移
popstateイベント(App Router)ブラウザの戻る/進むボタン
カスタムナビゲーション関数リンククリックによる遷移

実装のポイントは以下の通りです。

  1. 状態管理を適切に行う: isDirtyフラグでフォームの変更状態を追跡
  2. クリーンアップを忘れない: useEffectの戻り値でイベントリスナーを解除
  3. ユーザー体験を考慮する: 保存後や送信後は確認ダイアログを表示しない
  4. アクセシビリティに配慮する: カスタムモーダルを使う場合はキーボード操作に対応

これらの実装により、ユーザーの入力データを保護し、より安全で快適なフォーム体験を提供できます。

参考文献

円