フォーム入力を守る!Next.js(App Router)でのブラウザ離脱防止の実装方法
はじめに
フォーム入力中にブラウザを閉じたり、ページ中のボタンをクリックなどを防止して入力中の内容が失われることを防ぎたい事が多々あります。
そこで今回は、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
isDirty
がtrue
の場合は、確認モーダルを起動します。
onValuesChange
フォームに変更があった場合にisDirty
をtrue
に変更します。
他の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
isDirty
がtrue
の場合はイベントの動作をキャンセルします。beforeunload
と組み合わせる事で、遷移を防止しつつ、イベントもキャンセルします。
参考:
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
をコンポーネントツリーのトップレベルに配置し、useFormGuardContext
のconfirm
メソッドを離脱確認を入れたい場所で使用するだけです。
import { FormGuardProvider, useFormGuardContext } from "./useFormGuard";
export const FormWithFormGuard = () => {
return (
<FormGuardProvider>
<Form />
</FormGuardProvider>
);
};
const { confirm } = useFormGuardContext();
const handleClose = () => {
confirm(close);
};
終わりに
Providerパターンを使用することにより、ロジックを集約しつつ、シンプルに実装する事ができました。
参考
AppRouter環境下でのフォーム離脱実装においては、非常に参考させていただきました。この場を借りてお礼いたします。
Discussion