useContextを応用した離脱確認モーダルの制御
はじめに
フォームを入力しているときに誤ってブラウザバックやタブ削除をしてしまい、入力内容が全部吹っ飛んでしまった経験は誰しもあると思います。氏名や住所入れるくらいのフォームなら「もう一回やってやるか…」という気持ちになりますが、就職・転職活動等で使う職務経歴といったものをもう一回入力する気にはならないでしょう。
それを防ぐために、ページから離脱する際に確認モーダルを表示することができます。
ページの離脱を検知してただアラートを出すだけでは離脱アラートとしてはちょっと不十分です。例えば、何も入力・変更をしていない状態で離脱しようとしてアラートが出たら、「変更してないんだが?」という感じでみなさんならきっとイラッとしてしまいますよね。そこで、フォームの入力内容に変更があるのか否かを調べた上で離脱アラートの表示・非表示の制御を行いたくなります。(離脱アラートの実装はこちらの記事をご参照ください。ここではusePageLeaveConfirmation
の引数がtrue
ならアラートが表示されるようにしています)。
この記事ではまず構成がシンプルなフォームの場合の離脱アラートの実装を紹介します。その後、本題であるどのページからでも離脱アラートが呼び出せるようにする実装をuseContextを用いて行っていきます。
※フォームの実装はreact-hook-formを使っています
シンプルなフォームの場合
シンプルなフォームの場合であれば、そのコンポーネントでuseForm
を呼んで、formState.isDirty
がtrue
かfalse
かでアラートの出し分けをすれば実現できます。isDirty
はdefaultValues
からの差分の有無を表しています。そのため、入力内容を変更したけど元の値に戻した、という場合はisDirty=false
になります。
const schema = z.object({
form1: z.string(),
});
type Schema = z.infer<typeof schema>;
export default function Form() {
const {
handleSubmit,
register,
formState: { isDirty, isSubmitSuccessful },
} = useForm<Schema>({
defaultValues: { form1: "" },
});
useLeaveConfirm(isDirty);
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
}, [isSubmitSuccessful, reset]);
const onSubmit = (data: Schema) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<p>Form1</p>
<input {...register("form1")} type="text" />
<button type="submit">Form1 Submit</button>
</form>
);
useEffect
内ではonSubmit
の処理が成功した後に行う処理を書いていて、reset()
でフォームの値や状態をリセットしています。これにより、true
だったisDirty
がリセットされてfalse
になります。reset()
を行わないと、submitされた後もisDirty=true
が維持され離脱アラートが表示されてしまいます。
「reset()
をonSubmit
の最後でやればよいのでは?」と思う方もいらっしゃると思いますが、useEffect
を使ってreset
処理を行うように公式ドキュメントの”Submit with Reset”のコード内にコメントで書かれています(理由は定かではないですが、コメントから察するにsubmitが確実に成功した段階でリセット処理を行うようにしてね、ということかも?)
複数のフォームがコンポーネントで切り出されているとき
ではこの場合はどうでしょうか。
export default function Forms() {
return (
<>
<Form1 />
<Form2 />
</>
);
}
const form1Schema = z.object({ form1: z.string() });
type Form1Schema = z.infer<typeof form1Schema>;
const Form1 = () => {
const {
register,
handleSubmit,
formState: { isDirty },
} = useForm<Form1Schema>({
resolver: zodResolver(form1Schema),
defaultValues: { form1: "" },
});
const onSubmit = (data: Form1Schema) => console.log(data);
usePageLeaveConfirmation(isDirty);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<p>Form1</p>
<input {...register("form1")} type="text" />
<button type="submit">Form1 Submit</button>
</form>
);
};
const form2Schema = z.object({ form2: z.string() });
type Form2Schema = z.infer<typeof form2Schema>;
const Form2 = () => {
const {
register,
handleSubmit,
formState: { isDirty },
} = useForm<Form2Schema>({
resolver: zodResolver(form2Schema),
defaultValues: { form2: "" },
});
const onSubmit = (data: Form2Schema) => console.log(data);
usePageLeaveConfirmation(isDirty);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<p>Form2</p>
<input {...register("form2")} type="text" />
<button type="submit">Form2 Submit</button>
</form>
);
};
各フォームをコンポーネントに切り分け、それを親となるForms
でまとめて呼び出しています。1ページ内に複数のフォームがある点は先と同じです。それぞれのフォームが簡易なものであれば、先のようにコンポーネントに切り出さなくても良いですが、肥大化してきた場合はこのような実装になるかと思います。
フォームが1つのコンポーネントで完結しているときの実装をForm1, Form2
にそのまま書いています。これでも一応動くものの、各コンポーネントが離脱アラートを呼び出すことになります。そのため、1回ブラウザバックしてアラートのOKボタンを押した後にもう一度アラートが表示されてしまいます。
isDirty=true
の状態のフィールドの数にかかわらず、離脱アラートを1回だけ表示できるようにするためには、ページ内に表示されているすべてのフィールドのisDirty
の値を何かしらの方法で一元管理して、isDirty=true
のフィールドが1つでもあればアラートを表示、すべてのフィールドのisDirty=false
であればアラートを表示しないというようにすればよさそうです。
フィールドのisDirty
を一元管理する方法として、ぱっと思いつくのは「親のForms
コンポーネントで各フィールドのisDirty
を格納するstate
の配列areDirties
を準備し、子のForm1, Form2
でareDirties
を更新する」というものです。
実装例を見てみましょう。
export default function Forms() {
const [areDirties, setAreDirties] = useState([false, false]);
const changeOwnIsDirty = (index: number, isDirty: boolean) => {
setAreDirties((prev) => prev.map((_, i) => (i === index ? isDirty : prev[i])));
};
usePageLeaveConfirmation(areDirties.some((isDirty) => isDirty));
return (
<>
<Form1 changeOwnIsDirty={changeOwnIsDirty} index={0} />
<Form2 changeOwnIsDirty={changeOwnIsDirty} index={1} />
</>
);
}
const form1Schema = z.object({ form1: z.string() });
type Form1Schema = z.infer<typeof form1Schema>;
type FormProps = {
changeOwnIsDirty: (index: number, isDirty: boolean) => void;
index: number;
};
const Form1 = ({ changeOwnIsDirty, index }: FormProps) => {
const {
register,
handleSubmit,
reset,
formState: { isDirty, isSubmitSuccessful },
} = useForm<Form1Schema>({
resolver: zodResolver(form1Schema),
defaultValues: { form1: "" },
});
useEffect(() => {
changeOwnIsDirty(index, isDirty);
}, [isDirty, index, changeOwnIsDirty]);
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
}, [isSubmitSuccessful, reset]);
const onSubmit = (data: Form1Schema) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<p>Form1</p>
<input {...register("form1")} type="text" />
<button type="submit">Form1 Submit</button>
</form>
);
};
const Form2 = ...
}
親のForms
で各フォームのisDirty
の値を格納する配列areDirties
を用意します。子コンポーネントにindex
を振ることで、changeOwnIsDirty
によって子コンポーネント内では自身に対応するareDirties
の要素だけを変更することができます(index=0
のコンポーネントはareDirties[0]
だけを更新できる)。
子コンポーネントForm1, Form2
がシンプルなフォームであればこの実装でも最悪良いかなぁという気がしますが、親子間の依存関係が強くなってしまっています。もしForm1
をどこかで使いまわしたい、というときに流用先の親でForms
のようにstate
を準備して。。。のように使い方が強制されることになります。また、子のForm1
の中がネストされていてForm1
の何階層か下でuseForm
を呼び出している、という場合に呼び出している箇所まで親からchangeOwnIsDirty
などのpropsをバケツリレーをしなくてはいけなくなります。バケツリレーを防止するためにcontext
を使う方法もありますが、これも親子間の依存関係が強くなりForm1
が使いまわしにくくなります。加えて、この実装のままだと他のページのフォームにも離脱アラートを出したい、となったときも面倒です。
離脱アラートを全ページに共通化
どのページでも離脱アラートを呼び出せるようにするために、先のForms
でやっていたようにフォームの状態を管理・更新するcontextを用意して、/page/_app.tsx
内でProviderを使えばよさそうです。
export default function App({ pageProps }: AppProps) {
return (
<LeaveConfirmProvider>
<Component {...pageProps} />
</LeaveConfirmProvider>
);
}
実装
先の例のconst [areDirties, setAreDirties] = useState([false, false])
のようにフォームの数と同じ長さの配列を使った状態の管理では、どこでもアラートを呼び出せるようにする実装は実現できません。配列の長さも不明ですし、配列の何番目の要素がどのフォームの状態を表しているのかをtrue/false
の2値で管理することはできません。そこで、idを使ってフォームの状態を管理することにします。
コードの全体は以下のようになります。
type Props = {
children: React.ReactNode;
};
const LeaveConfirmContext = createContext({} as ReturnType<typeof useProvideLeaveConfirm>);
export const useLeaveConfirm = (isDirty: boolean) => {
const formId = useId();
const { registerEditingFormId, unregisterEditingFormId } = useContext(LeaveConfirmContext);
useEffect(() => {
if (isDirty) {
registerEditingFormId(formId);
return () => {
unregisterEditingFormId(formId);
};
}
}, [formId, isDirty, registerEditingFormId, unregisterEditingFormId]);
};
export const LeaveConfirmProvider: React.FC<Props> = ({ children }) => {
const leaveConfirm = useProvideLeaveConfirm();
return <LeaveConfirmContext.Provider value={leaveConfirm}>{children}</LeaveConfirmContext.Provider>;
};
export const useProvideLeaveConfirm = () => {
const [editingFormIds, setEditingFormIds] = useState<string[]>([]);
const registerEditingFormId = useCallback((id: string) => {
setEditingFormIds((ids) => Array.from(new Set([...ids, id])));
}, []);
const unregisterEditingFormId = useCallback((id: string) => {
setEditingFormIds((ids) => ids.filter((_id) => _id !== id));
}, []);
useDisplayLeaveConfirm(editingFormIds.length > 0);
return {
registerEditingFormId,
unregisterEditingFormId,
};
};
useProvideLeaveConfirm
でapp全体で共有するstate
や関数を定義しています。setState
をラップした関数を返り値にすることで、どのページからでもeditingFormIds
を更新できるようにしています。
(余談ですが、useCallback
を使うことで、useLeaveConfirm
が呼び出される度に関数が新しく生成されuseLeaveConfirm
内のuseEffect
が思わぬタイミングで実行されるのを防いでいます。)
useLeaveConfirm
内のuseEffect
のクリーンアップ処理は忘れないように気をつけましょう。これがないと、コンポーネントがアンマウントされてもidがeditingFormIds
に残り続けてしてしまいます。
呼び出し元のフォームではこんな感じでかなりシンプルになります。
const Form1 = () => {
const {
register,
handleSubmit,
reset,
formState: { isDirty, isSubmitSuccessful },
} = useForm();
usePageLeaveConfirmation(isDirty);
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
}, [isSubmitSuccessful, reset]);
return (
...
)
}
挙動を順に説明するとこのようになります。
- 離脱アラートを表示したいコンポーネント内で
useLeaveConfirm
を呼び出す -
useLeaveConfirm
内で呼び出し元に対して一意のidを生成 -
isDirty=true
ならcontext内で定義した配列editingFormIds
に生成したidを格納し更新 -
editingFormIds.length > 0
ならアラートを表示 - ページ離脱やフォームのsubmit完了後、
editingFormIds
内のidを削除
まとめ
離脱アラートはあったら嬉しいちょっとした機能ですが、実際に実装してみるとなかなか難しいところが多かったなぁという印象です。reactのライフサイクルやstate
管理、使い回しやすいコンポーネントの実装方法など、色々勉強になりました。皆さんの学びになれば嬉しいです。
Discussion
構成を踏襲しつつ、FormIdでMap管理して、ConfirmPopupも入れたデモでチャレンジしてみました