🦔

フォーム入力を守る!Next.js(App Router)でのブラウザ離脱防止の実装方法

2024/06/23に公開

はじめに

フォーム入力中にブラウザを閉じたり、ページ中のボタンをクリックなどを防止して入力中の内容が失われることを防ぎたい事が多々あります。
そこで今回は、Next.js(App router)に対応したフォーム離脱を防止する機能を実装してみます。

仕様

今回作る機能の仕様として以下を考えてみます。

表示条件

フォームの入力内容が変更されたとき

フォームへの入力を監視し、変更があった場合は、離脱防止の確認モーダルを表示します。入力された内容を元に戻しても変更された判定は元に戻しません。

表示タイミング

ブラウザ離脱時

  • ブラウザのタブを閉じる
  • タブを切り替える
  • ブラウザを終了する
    この場合は、以下のような確認を表示します(このUIは編集できません)

他の要素をクリックする

ユーザーがページ内の任意の要素をクリックして別の処理を行おうとした場合にはこんなメッセージを表示します。
「破棄する」をクリックしたら処理が続行されます。

実装

Providerパターンで実装し、どのコンポーネントからも確認モーダルを表示できるようにします。

この実装ではmantine/form上で動作させていますが、react-hook-formなど他のライブラリでも同様に実装できるはずです。

まずはhooksを作ります。

useFormGuardを実装する

import {  useState } from "react";

import { isEmptyObject } from "@/utils";

type OnValuesChange = (current: Record<string, unknown>, previous: Record<string, unknown>) => void;

type SubmitHandler = () => void;

type ConfirmOpenState = {
  isOpen: true;
  submitHandler: SubmitHandler;
};

type ConfirmCloseState = {
  isOpen: false;
  submitHandler: null;
};

const useFormGuard = () => {
  const [isDirty, setIsDirty] = useState(false);
  const [{ isOpen, submitHandler }, setConfirmState] = useState<
    ConfirmOpenState | ConfirmCloseState
  >({
    isOpen: false,
    submitHandler: null,
  });

  const confirm = (action: SubmitHandler) => {
    if (!isDirty) {
      action();
      return;
    }

    setConfirmState({ isOpen: true, submitHandler: action });
  };

  const onValuesChange: OnValuesChange = (_, previous) => {
    if (isEmptyObject(previous)) {
      return;
    }
    setIsDirty(true);
  };

  const resetFormValuesChange = () => {
    setIsDirty(false);
  };

  const closeModal = () => {
    setConfirmState({ isOpen: false, submitHandler: null });
  };

  return {
    isDirty,
    confirm,
    closeModal,
    onValuesChange,
    resetFormValuesChange,
    submitHandler,
    isOpen,
  };
};

isDirty

フォームに変更状態はisDirtyで管理します。trueの場合は離脱時に確認を行います。

confirm

isDirtytrueの場合は、確認モーダルを起動します。

onValuesChange

フォームに変更があった場合にisDirtytrueに変更します。
他のformライブラリで使用する場合はこの関数を適切な場所で実行してあげればよいかと思います。

resetFormValuesChange

フォーム送信後や、保存せず「破棄する」を選択した場合にisDirtyをfalseに設定します。これにより、次回フォームに入力されるまで確認モーダルを表示しません。

つづいて、Providerを実装します。

FormGuardProviderを実装する

export const FormGuardContext = createContext<UseFormGuardContext | null>(null);

export function FormGuardProvider({ children }: FormGuardProviderProps) {
  const {
    confirm,
    onValuesChange,
    resetFormValuesChange,
    isDirty,
    isOpen,
    closeModal,
    submitHandler,
  } = useFormGuard();

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

    window.addEventListener("beforeunload", handleBeforeUnload);

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

  const handleSubmit = () => {
    submitHandler && submitHandler();
    closeModal();
    resetFormValuesChange();
  };

  return (
    <FormGuardContext.Provider value={{ confirm, onValuesChange, resetFormValuesChange }}>
      {children}
      <ConfirmDiscardModal isOpen={isOpen} onClose={closeModal} onSubmit={handleSubmit} />
    </FormGuardContext.Provider>
  );
}

export const useFormGuardContext = () => {
  const context = useContext(FormGuardContext);
  if (!context) {
    throw new Error("useFormGuardContext was called outside of FormGuardProvider context");
  }

  return context;
};

handleBeforeUnload

isDirtytrueの場合はイベントの動作をキャンセルします。beforeunloadと組み合わせる事で、遷移を防止しつつ、イベントもキャンセルします。

参考:
https://developer.mozilla.org/ja/docs/Web/API/Window/beforeunload_event

ConfirmDiscardModalを実装する

離脱時に確認するモーダルを実装します。今回はmantine使用していますが、他のUIライブラリでも問題ないです。

import { Button, Modal } from "@mantine/core";

type Props = {
  isOpen: boolean;
  onClose: () => void;
  onSubmit: () => void;
};

export const ConfirmDiscardModal = ({ isOpen, onClose, onSubmit }: Props) => {
  return (
    <Modal
      opened={isOpen}
      onClose={onClose}
      title="編集内容を破棄しますか?"
    >
      <div style={{
          marginBottom: 20,
          fontSize: 14,
          fontStyle: 'normal',
          fontWeight: 300,
          lineHeight: '130%',
          color: '#000'
      }}>
        問題なければ破棄するをクリック!
      </div>
      <div style={{
          display: 'flex',
          gap: 8,
          justifyContent: 'flex-end',
          marginTop: 20
      }}>
        <Button color="gray" onClick={onClose} radius={32}>
          キャンセル
        </Button>
        <Button onClick={onSubmit} radius={32}>
          破棄する
        </Button>
      </div>
    </Modal>
  );
};

ConfirmDiscardModalをProviderの中に配置する事で、一連の処理を一元管理できます。

使用方法

FormGuardProviderをコンポーネントツリーのトップレベルに配置し、useFormGuardContextconfirmメソッドを離脱確認を入れたい場所で使用するだけです。

import { FormGuardProvider, useFormGuardContext } from "./useFormGuard";

export const FormWithFormGuard = () => {
  return (
    <FormGuardProvider>
      <Form />
    </FormGuardProvider>
  );
};
const { confirm } = useFormGuardContext();

const handleClose = () => {
  confirm(close);
};

終わりに

Providerパターンを使用することにより、ロジックを集約しつつ、シンプルに実装する事ができました。

参考

https://zenn.dev/nino/articles/fafa5053364c03

AppRouter環境下でのフォーム離脱実装においては、非常に参考させていただきました。この場を借りてお礼いたします。

SMARTCAMP Engineer Blog

Discussion