🚀

useContextを応用した離脱確認モーダルの制御

2024/07/10に公開
1

はじめに

フォームを入力しているときに誤ってブラウザバックやタブ削除をしてしまい、入力内容が全部吹っ飛んでしまった経験は誰しもあると思います。氏名や住所入れるくらいのフォームなら「もう一回やってやるか…」という気持ちになりますが、就職・転職活動等で使う職務経歴といったものをもう一回入力する気にはならないでしょう。

それを防ぐために、ページから離脱する際に確認モーダルを表示することができます。

ページの離脱を検知してただアラートを出すだけでは離脱アラートとしてはちょっと不十分です。例えば、何も入力・変更をしていない状態で離脱しようとしてアラートが出たら、「変更してないんだが?」という感じでみなさんならきっとイラッとしてしまいますよね。そこで、フォームの入力内容に変更があるのか否かを調べた上で離脱アラートの表示・非表示の制御を行いたくなります。(離脱アラートの実装はこちらの記事をご参照ください。ここではusePageLeaveConfirmationの引数がtrueならアラートが表示されるようにしています)。

この記事ではまず構成がシンプルなフォームの場合の離脱アラートの実装を紹介します。その後、本題であるどのページからでも離脱アラートが呼び出せるようにする実装をuseContextを用いて行っていきます。
※フォームの実装はreact-hook-formを使っています

シンプルなフォームの場合

シンプルなフォームの場合であれば、そのコンポーネントでuseFormを呼んで、formState.isDirtytruefalseかでアラートの出し分けをすれば実現できます。isDirtydefaultValuesからの差分の有無を表しています。そのため、入力内容を変更したけど元の値に戻した、という場合は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, Form2areDirtiesを更新する」というものです。

実装例を見てみましょう。

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 (
    ...
    )
}

挙動を順に説明するとこのようになります。

  1. 離脱アラートを表示したいコンポーネント内でuseLeaveConfirmを呼び出す
  2. useLeaveConfirm内で呼び出し元に対して一意のidを生成
  3. isDirty=trueならcontext内で定義した配列editingFormIdsに生成したidを格納し更新
  4. editingFormIds.length > 0ならアラートを表示
  5. ページ離脱やフォームのsubmit完了後、editingFormIds内のidを削除

まとめ

離脱アラートはあったら嬉しいちょっとした機能ですが、実際に実装してみるとなかなか難しいところが多かったなぁという印象です。reactのライフサイクルやstate管理、使い回しやすいコンポーネントの実装方法など、色々勉強になりました。皆さんの学びになれば嬉しいです。

Discussion

nap5nap5

皆さんの学びになれば嬉しいです。

構成を踏襲しつつ、FormIdでMap管理して、ConfirmPopupも入れたデモでチャレンジしてみました