🌟

カスタム確認ダイアログをReact hooksで実装する

2023/12/04に公開

これは CastingONE Advent Calendar 2023 4日目の記事です。

始めに

確認ダイアログを使う際に、window.confirmだと以下のようにreturnでtrue/falseで返ってくるため処理の流れが読みやすいです。

window.confirmを使った確認ロジック
const Page: FC = () => {
  return (
    <Button
      variant="contained"
      onClick={() => {
        const result = window.confirm("本当によろしいですか?");
        if (result === true) {
          enqueueSnackbar("実行しました", {
            variant: "success"
          });
        }
      }}
    >
      確認
    </Button>
  )
}

しかし自作のダイアログを使った場合、よくisOpenのような開閉フラグを用意して表示することが多く、処理の流れが一度途切れてしまいます。

よくやる自作ダイアログの使用例
const Page: FC = () => {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <Box>
      <Button
        variant="contained"
        onClick={() => {
          setIsOpen(true);
          // ここで処理が途切れてしまう
        }}
      >
        確認
      </Button>
      <Dialog
        open={isOpen}
        onClose={() => {
          setIsOpen(false);
        }}
      >
        <DialogTitle>確認ダイアログ</DialogTitle>
        <DialogContent>本当によろしいですか?</DialogContent>
        <DialogActions>
          <Button
            variant="outlined"
            onClick={() => {
              setIsOpen(false);
            }}
          >
            キャンセル
          </Button>
          <Button
            variant="contained"
            onClick={() => {
              // 確認がOKだった時の処理が以下になるが、UIのコードも混ざっていて処理の流れが掴みづらい
              enqueueSnackbar("実行しました", {
                variant: "success"
              });
              setIsOpen(false);
            }}
          >
            OK
          </Button>
        </DialogActions>
      </Dialog>
    </Box>
  )
}

これが以下のような感じで確認メソッドを実行したら自動でダイアログが開き、その結果がコールバックとして受け取れるようになったら処理の流れが分かりやすく、スッキリしたコードになるのでは思い、それを作ることができるhooksを作ってみましたので備忘録としてまとめました。

カスタムダイアログでも実行時にコールバックで受け取れるhooksの使用イメージ
// 呼ぶだけで目的のカスタムダイアログを表示できるhooks
const useSimpleCustomConfirm = () => {
  // カスタムUIの内容を定義する汎用的なhooksを呼んで、UI部分だけ定義する
  return useCustomConfirm({
    renderContent: ({ ok, cancel }) => {
      return (
        <>
          <DialogTitle>シンプル確認ダイアログ</DialogTitle>
          <DialogContent>本当によろしいですか?</DialogContent>
          <DialogActions>
            <Button variant="outlined" onClick={cancel}>
              キャンセル
            </Button>
            <Button variant="contained" onClick={ok}>
              OK
            </Button>
          </DialogActions>
        </>
      )
    }
  });
}

const Page: FC = () => {
  // 確認ダイアログを出すメソッドとダイアログ表示用のReact要素を返す
  const { confirm, dialogElement } = useSimpleCustomConfirm();

  return (
    <Box>
      <Button
        variant="contained"
        onClick={() => {
          // 確認メソッドを実行するとダイアログが表示されて、OKであった場合の処理を引数にかける
          confirm({
            onOk: () => {
              enqueueSnackbar("実行しました", {
                variant: "success",
              });
            },
          });
        }}
      >
        確認
      </Button>
      {dialogElement}
    </Box>
  );
};

カスタム確認ダイアログをReact hooksで実装する方法

上の例で見せたようなものを実装していきますが、更に確認ダイアログを開く際に追加のパラメータを渡せたり、非同期実行時にローディングを出すなど様々なユースケースにも対応できるように拡張していきたいと思います。

シンプルなカスタムUIに対応できるhooksを作る

まずは上の例で見せたコンテンツ部分も自由に定義できるuseCustomConfirmを作ります。以下のようなコードを書くことで上の例で見せたシンプルなカスタム確認ダイアログを作ることができます。

シンプルなカスタムUIにできるhooks
import { ReactElement, useState, useCallback } from "react";

import { Dialog } from "@mui/material";

export type UseCustomConfirmOption = {
  /** コンテンツを描画する */
  renderContent: (props: {
    /** OKアクションを実行する */
    ok: () => void;
    /** キャンセルする */
    cancel: () => void;
  }) => ReactElement;
};

/** クリック時に実行されるハンドラー */
type ActionHandler = () => void;

export type ReturnUseCustomConfirm = {
  /** 確認ダイアログを出す */
  confirm: (props: { onOk: ActionHandler }) => void;
  /** ダイアログ要素 */
  dialogElement: ReactElement;
};

/**
 * カスタマイズされた確認ダイアログを呼び出すhooks
 */
export const useCustomConfirm = function ({
  renderContent,
}: UseCustomConfirmOption): ReturnUseCustomConfirm {
  // ダイアログコンテンツ内でOKボタンを押したときに実行するメソッドをstateで持つ
  const [execOk, setExecOk] = useState<ActionHandler | null>(null);

  const handleOk = useCallback(() => {
    if (execOk == null) {
      return;
    }
    execOk();
    setExecOk(null);
  }, [execOk]);

  const handleClose = useCallback(() => {
    setExecOk(null);
  }, []);

  const confirm: ReturnUseCustomConfirm["confirm"] = useCallback(
    ({ onOk }) => {
      setExecOk(() => onOk);
    },
    []
  );

  // ダイアログ内に表示するReactElementを生成する
  const dialogContentElement = renderContent({
    ok: handleOk,
    cancel: handleClose,
  });

  // OK時の実行メソッドを持っているかでダイアログを開くか判断する
  const isOpen = execOk != null;
  const dialogElement: ReturnUseCustomConfirm["dialogElement"] = (
    <Dialog open={isOpen} onClose={handleClose}>
      {dialogContentElement}
    </Dialog>
  );

  return {
    confirm,
    dialogElement,
  };
};

追加のパラメータを渡すことができるようにする

上のコードだけでも色々カスタマイズできると思いますが、開く際にパラメータを渡せるようになると更に自由度が上がると思うので、それができるようにします。

confirm実行時に追加のパラメータを渡せるようにする
 import { ReactElement, useState, useCallback } from "react";

 import { Dialog } from "@mui/material";

+/** ダイアログコンテンツで追加する型 */
+type AdditionalContentProps<ContentProps = undefined> = [ContentProps] extends [
+  undefined
+]
+  ? {}
+  : {
+      props: ContentProps;
+    };

-export type UseCustomConfirmOption = {
+export type UseCustomConfirmOption<ContentProps = undefined> = {
   /** コンテンツを描画する */
   renderContent: (props: {
+    /** コンテンツに渡すprops */
+    props?: ContentProps;
     /** OKアクションを実行する */
     ok: () => void;
     /** キャンセルする */
     cancel: () => void;
   }) => ReactElement;
 };

 /** クリック時に実行されるハンドラー */
 type ActionHandler = () => void;

-export type ReturnUseCustomConfirm = {
+export type ReturnUseCustomConfirm<ContentProps = undefined> = {
   /** 確認ダイアログを出す */
   confirm: (
     props: {
      onOk: ActionHandler
+    } & AdditionalContentProps<ContentProps>
   ) => void;
   /** ダイアログ要素 */
   dialogElement: ReactElement;
 };

 /**
  * カスタマイズされた確認ダイアログを呼び出すhooks
  */
-export const useCustomConfirm = function ({
+export const useCustomConfirm = function <ContentProps = undefined>({
   renderContent,
-}: UseCustomConfirmOption): ReturnUseCustomConfirm {
+}: UseCustomConfirmOption<ContentProps>): ReturnUseCustomConfirm<ContentProps> {
   // ダイアログコンテンツ内でOKボタンを押したときに実行するメソッドをstateで持つ
   const [execOk, setExecOk] = useState<ActionHandler | null>(null);
+  // 追加で渡すpropsをstateで持つ
+  const [contentProps, setContentProps] = useState<ContentProps>();

   const handleOk = useCallback(() => {
     if (execOk == null) {
       return;
     }
     execOk();
     setExecOk(null);
   }, [execOk]);

   const handleClose = useCallback(() => {
     setExecOk(null);
   }, []);

-  const confirm: ReturnUseCustomConfirm["confirm"] = useCallback(
-    ({ onOk }) => {
+  const confirm: ReturnUseCustomConfirm<ContentProps>["confirm"] = useCallback(
+    ({ onOk, ...restProps }) => {
+      if ("props" in restProps) {
+        setContentProps(restProps.props as ContentProps);
+      }
       setExecOk(() => onOk);
     },
     []
   );

   // ダイアログ内に表示するReactElementを生成する
   const dialogContentElement = renderContent({
+    props: contentProps,
     ok: handleOk,
     cancel: handleClose,
   });

   // OK時の実行メソッドを持っているかでダイアログを開くか判断する
   const isOpen = execOk != null;
   const dialogElement: ReturnUseCustomConfirm["dialogElement"] = (
     <Dialog open={isOpen} onClose={handleClose}>
       {dialogContentElement}
     </Dialog>
   );

   return {
     confirm,
     dialogElement,
   };
 };

こんな感じで呼び出すことで、動的に変更して使えるようになりました。開く前からパラメータを渡したいケースはラップしたhooksの引数側で受け取って使用すればよく、その辺も自由に決められます。

追加のパラメータを渡す例
const useVariableSimpleCustomConfirm = ({ title }: { title: string }) => {
  return useCustomConfirm<{ content: string }>({
    renderContent: ({ props, ok, cancel }) => {
      if (props == null) {
        return <></>;
      }
      return (
        <>
          <DialogTitle>{title}</DialogTitle>
          <DialogContent>{props.content}</DialogContent>
          <DialogActions>
            <Button variant="outlined" onClick={cancel}>
              キャンセル
            </Button>
            <Button variant="contained" onClick={ok}>
              OK
            </Button>
          </DialogActions>
        </>
      );
    },
  });
};

const Page: FC = () => {
  const [contentText, setContentText] = useState("テキスト");
  const { confirm, dialogElement } = useVariableCustomConfirm({
    // hooks側でパラメータを渡すことも可能
    title: "パラメータ変更可能確認ダイアログ",
  });

  return (
    <Box>
      <TextField
        value={contentText}
        label="コンテンツ"
        size="small"
        onChange={(event) => {
          setContentText(event.target.value);
        }}
      />
      <br />
      <Button
        variant="contained"
        onClick={() => {
          confirm({
            // confirm実行時に追加のプロパティを渡す
            props: {
              content: contentText,
            },
            onOk: () => {
              enqueueSnackbar("実行しました", {
                variant: "success",
              });
            },
          });
        }}
      >
        確認
      </Button>
      {dialogElement}
    </Box>
  );
};

非同期実行時にローディングを出せるようにする

続いてonOk時の処理が非同期だった場合の対応もしたいと思います。実際のケースでは確認ダイアログでOKだった場合にAPIリクエストを投げて、成功したらダイアログを閉じるケースがあると思いますが、現状だと非同期処理が考慮されていないためすぐ閉じてしまいます。非同期処理中はローディング表示にして、成功した時にダイアログを閉じる作りにしておくと毎回ローディングの設定も書く必要がなくなって便利そうです。

非同期実行を考慮したhooksにする
 import { ReactElement, useState, useCallback } from "react";

 import { Dialog } from "@mui/material";

 /** ダイアログコンテンツで追加する型 */
 type AdditionalContentProps<ContentProps = undefined> = [ContentProps] extends [
   undefined
 ]
   ? {}
   : {
       props: ContentProps;
     };

 export type UseCustomConfirmOption<ContentProps = undefined> = {
   /** コンテンツを描画する */
   renderContent: (props: {
     /** コンテンツに渡すprops */
     props?: ContentProps;
+    /** 処理中か */
+    processing: boolean
     /** OKアクションを実行する */
     ok: () => void;
     /** キャンセルする */
     cancel: () => void;
   }) => ReactElement;
 };

 /** クリック時に実行されるハンドラー */
-type ActionHandler = () => void;
+type ActionHandler = () => void | Promise<void>;

 export type ReturnUseCustomConfirm<ContentProps = undefined> = {
   /** 確認ダイアログを出す */
   confirm: (
     props: {
      onOk: ActionHandler
     } & AdditionalContentProps<ContentProps>
   ) => void;
   /** ダイアログ要素 */
   dialogElement: ReactElement;
 };

 /**
  * カスタマイズされた確認ダイアログを呼び出すhooks
  */
 export const useCustomConfirm = function <ContentProps = undefined>({
   renderContent,
 }: UseCustomConfirmOption<ContentProps>): ReturnUseCustomConfirm<ContentProps> {
+  // 非同期処理中かを管理するフラグ
+  const [isProcessing, setIsProcessing] = useState(false);
   // ダイアログコンテンツ内でOKボタンを押したときに実行するメソッドをstateで持つ
   const [execOk, setExecOk] = useState<ActionHandler | null>(null);
   // 追加で渡すpropsをstateで持つ
   const [contentProps, setContentProps] = useState<ContentProps>();

   const handleOk = useCallback(() => {
     if (execOk == null) {
       return;
     }
-    execOk();
-    setExecOk(null);

+    const result = execOk();
+    // OK時の処理がPromiseでないときは即閉じる
+    if (!(result instanceof Promise)) {
+      setExecOk(null);
+      return;
+    }

+    // OK時の処理がPromiseだった場合、処理中のフラグを立てて、resolveした時に閉じるようにする
+    setIsProcessing(true);
+    result
+      .then(() => {
+        setExecOk(null);
+      })
+      .catch((err) => {
+        console.log("catch reject", err);
+      })
+      .finally(() => {
+        setIsProcessing(false);
+      });
   }, [execOk]);

   const handleClose = useCallback(() => {
+    // 処理中の時は閉じれないようにする
+    if (isProcessing) {
+      return;
+    }
     setExecOk(null);
-   }, []);
+   }, [isProcessing]);

  const confirm: ReturnUseCustomConfirm<ContentProps>["confirm"] = useCallback(
     ({ onOk, ...restProps }) => {
       if ("props" in restProps) {
         setContentProps(restProps.props as ContentProps);
       }
       setExecOk(() => onOk);
     },
     []
   );

   // ダイアログ内に表示するReactElementを生成する
   const dialogContentElement = renderContent({
     props: contentProps,
+    processing: isProcessing,
     ok: handleOk,
     cancel: handleClose,
   });

   // OK時の実行メソッドを持っているかでダイアログを開くか判断する
   const isOpen = execOk != null;
   const dialogElement: ReturnUseCustomConfirm["dialogElement"] = (
     <Dialog open={isOpen} onClose={handleClose}>
       {dialogContentElement}
     </Dialog>
   );

   return {
     confirm,
     dialogElement,
   };
 };

こんな感じで使用することを想定しています。折角なので非同期実行で成功/失敗を選択できたり、遅延待ち時間を設定できるようにしました。

confirmのonOk時に非同期実行する例
type AsyncExecType = "success" | "fail";
const useAsyncCustomConfirm = () => {
  return useCustomConfirm<{ asyncExecType: AsyncExecType; waitTime: number }>({
    renderContent: ({ props, processing, ok, cancel }) => {
      if (props == null) {
        return <></>;
      }
      const asyncExecTypeLabel =
        props.asyncExecType === "success" ? "成功" : "失敗";
      return (
        <>
          <DialogTitle>非同期実行を待つダイアログ</DialogTitle>
          <DialogContent>
            <Box>非同期実行種別: {asyncExecTypeLabel}</Box>
            <Box>遅延時間: {props.waitTime}ms</Box>
          </DialogContent>
          <DialogActions>
            <LoadingButton
              // 処理中はprocessingがtrueになるため、これでローディングの表示ができる
              loading={processing}
              variant="outlined"
              onClick={cancel}
            >
              キャンセル
            </LoadingButton>
            <LoadingButton
              loading={processing}
              variant="contained"
              onClick={ok}
            >
              OK
            </LoadingButton>
          </DialogActions>
        </>
      );
    },
  });
};

const Page: FC = () => {
  const [waitTime, setWaitTime] = useState(1000);
  const [asyncExecType, setAsyncExecType] = useState<AsyncExecType>("success");
  const { confirm, dialogElement } = useAsyncCustomConfirm();

  return (
    <Box>
      {/* waitTime, asyncExecTypeの設定部分は本質的な部分ではないので割愛 */}
      <br />
      <Button
        variant="contained"
        onClick={() => {
          confirm({
            props: {
              asyncExecType,
              waitTime,
            },
            onOk: () => {
              // Promiseを返すことで解決するまでprocessingフラグが立って、ローディングが出る
              // resolveした場合は自動で閉じて、rejectの場合はprocessingフラグだけOFFにしてその場に止まる
              return new Promise((resolve, reject) => {
                window.setTimeout(() => {
                  switch (asyncExecType) {
                    case "success":
                      resolve();
                      enqueueSnackbar("成功しました!", {
                        variant: "success",
                      });
                      return;
                    case "fail":
                      reject();
                      enqueueSnackbar("失敗しました。。", {
                        variant: "error",
                      });
                      return;
                  }
                }, waitTime);
              });
            },
          });
        }}
      >
        確認
      </Button>
      {dialogElement}
    </Box>
  );
};

確認ダイアログ内でフォーム入力に対応する

最後に以下のような文字を入力して最終確認するような確認ダイアログをつけるようにすることを考えます。

これはこれ以上useCustomConfirmを拡張する必要がなく、ラップしたhooksの方で調整するだけで実現できます。ローカルステートはコンポーネントにしないと持てないためそれ専用のフォームコンポーネントを作ってrenderContentメソッドで呼び出すようにします。

useCustomFormを使って確認フォームを付ける例
const ConfirmForm: FC<{
  confirmText: string;
  processing: boolean;
  onOk: () => void;
  onCancel: () => void;
}> = ({ confirmText, processing, onOk, onCancel }) => {
  const [inputText, setInputText] = useState("");
  return (
    <>
      <DialogContent>
        <Box>
          確認のため「
          <Typography component="span" fontWeight="bold">
            {confirmText}
          </Typography>
          」と入力してください。
        </Box>
        <TextField
          value={inputText}
          fullWidth
          size="small"
          onChange={(event) => {
            setInputText(event.target.value);
          }}
        />
      </DialogContent>
      <DialogActions>
        <LoadingButton
          loading={processing}
          variant="outlined"
          onClick={onCancel}
        >
          キャンセル
        </LoadingButton>
        <LoadingButton
          loading={processing}
          variant="contained"
          color="error"
          disabled={inputText !== confirmText}
          onClick={onOk}
        >
          OK
        </LoadingButton>
      </DialogActions>
    </>
  );
};
const useCustomConfirmWithForm = () => {
  return useCustomConfirm<{ confirmText: string }>({
    renderContent: ({ props, processing, ok, cancel }) => {
      if (props == null) {
        return <></>;
      }
      return (
        <>
          <DialogTitle>確認フォーム付きダイアログ</DialogTitle>
          <ConfirmForm
            confirmText={props.confirmText}
            processing={processing}
            onOk={ok}
            onCancel={cancel}
          />
        </>
      );
    }
  });
};
useCustomConfirmWithFormの呼び出し例
const Page: FC = () => {
  const { confirm, dialogElement } = useCustomConfirmWithForm();

  return (
    <Box>
      <Button
        variant="contained"
        color="error"
        onClick={() => {
          confirm({
            props: {
              confirmText: "confirm"
            },
            onOk: () => {
              return new Promise((resolve) => {
                window.setTimeout(() => {
                  resolve();
                  enqueueSnackbar("実行しました", {
                    variant: "success"
                  });
                }, 1000);
              });
            }
          });
        }}
      >
        確認
      </Button>
      {dialogElement}
    </Box>
  );
};

submit部分も拡張したuseCustomFormDialogを作る

今までは確認ダイアログのことだけにフォーカスを当てて作っていましたが、ボタン押下時にフォームダイアログ内で入力した内容も受け取れるようにしたら更に自由度を上げることができるかなと思い、useCustomFormDialogも作ってみました。

送信データを受け取れるように更に拡張する
 import { ReactElement, useState, useCallback } from "react";

 import { Dialog } from "@mui/material";

 /** ダイアログコンテンツで追加する型 */
 type AdditionalContentProps<ContentProps = undefined> = [ContentProps] extends [
   undefined
 ]
   ? {}
   : {
       props: ContentProps;
     };

+/** 送信メソッドの型 */
+type SubmitExecuter<SubmitData = undefined> = [SubmitData] extends [undefined]
+  ? () => void
+  : (submitData: SubmitData) => void;

 export type UseCustomFormDialogOption<
   ContentProps = undefined,
+  SubmitData = undefined
 > = {
   /** コンテンツを描画する */
   renderContent: (props: {
     /** コンテンツに渡すprops */
     props?: ContentProps;
     /** 処理中か */
     processing: boolean;
-    /** OKアクションを実行する */
-    ok: () => void;
+    /** 送信する */
+    submit: SubmitExecuter<SubmitData>;
     /** キャンセルする */
     cancel: () => void;
   }) => ReactElement;
 };

-/** クリック時に実行されるハンドラー */
-type ActionHandler = () => void | Promise<void>;
+/** 送信時に実行されるハンドラー */
+type SubmitHandler<SubmitData = undefined> = (
+  data: SubmitData
+) => void | Promise<void>;

 export type ReturnUseCustomFormDialog<
   ContentProps = undefined,
+  SubmitData = undefined
 > = {
-  /** 確認ダイアログを出す */
-  confirm: (
-    props: {
-      onOk: ActionHandler;
-    } & AdditionalContentProps<ContentProps>
-  ) => void;
+  /** フォームダイアログを表示する */
+  open: (
+    props: {
+      onSubmit: SubmitHandler<SubmitData>;
+    } & AdditionalContentProps<ContentProps>
+  ) => void;
   /** フォームダイアログを閉じる */
   close: () => void;
   /** ダイアログ要素 */
   dialogElement: ReactElement;
 };

 export const useCustomFormDialog = function <
   ContentProps = undefined,
+  SubmitData = undefined
 >({
   renderContent
 }: UseCustomFormDialogOption<
   ContentProps,
+  SubmitData
 >): ReturnUseCustomFormDialog<ContentProps, SubmitData> {
   const [isProcessing, setIsProcessing] = useState(false);
-  const [execOk, setExecOk] = useState<ActionHandler | null>(null);
+  const [execSubmit, setExecSubmit] = useState<SubmitHandler<
    SubmitData
  > | null>(null);
   const [contentProps, setContentProps] = useState<ContentProps>();

   const handleSubmit = useCallback(
+    (data: SubmitData) => {
       if (execSubmit == null) {
         return;
       }
-      const result = execOk();
+      const result = execSubmit(data);
       if (!(result instanceof Promise)) {
         setExecSubmit(null);
         return;
       }

       setIsProcessing(true);
       result
         .then(() => {
           setExecSubmit(null);
         })
         .catch((err) => {
           console.log("catch reject", err);
         })
         .finally(() => {
           setIsProcessing(false);
         });
     },
     [execSubmit]
   );

   const handleClose = useCallback(() => {
     // 処理中は閉じれないようにする
     if (isProcessing) {
       return;
     }
     setExecSubmit(null);
   }, [isProcessing]);

   const open: ReturnUseCustomFormDialog<
     ContentProps,
     SubmitData
   >["open"] = useCallback(({ onSubmit, ...restProps }) => {
     if ("props" in restProps) {
       setContentProps(restProps.props as ContentProps);
     }
     setExecSubmit(() => onSubmit);
   }, []);

   const dialogContentElement = renderContent({
     props: contentProps,
     processing: isProcessing,
-    ok: handleOk,
+    submit: handleSubmit as SubmitExecuter<SubmitData>,
     cancel: handleClose
   });

   const isOpen = execSubmit != null;
   const dialogElement: ReturnUseCustomFormDialog<
     ContentProps,
+    SubmitData
   >["dialogElement"] = (
     <Dialog open={isOpen} onClose={handleClose}>
       {dialogContentElement}
     </Dialog>
   );

   return {
     open,
     close: handleClose,
     dialogElement
   };
 };

これを使うと以下のようなことができます。例はオマケみたいなものなので最初は折り畳んだ状態で掲載しますので興味がある方は開いてみてください。

例: シンプルフォームダイアログ
シンプルフォームダイアログを呼ぶhooks
const SimpleCustomForm: FC<{
  onSubmit: (data: string) => void;
  onCancel: () => void;
}> = ({ onSubmit, onCancel }) => {
  const [text, setText] = useState("");
  return (
    <>
      <DialogContent>
        <TextField
          sx={{ mt: 1 }}
          value={text}
          label="テキスト"
          size="small"
          fullWidth
          onChange={(event) => {
            setText(event.target.value);
          }}
        />
      </DialogContent>
      <DialogActions>
        <Button variant="outlined" onClick={onCancel}>
          キャンセル
        </Button>
        <Button
          variant="contained"
          disabled={text === ""}
          onClick={() => {
            onSubmit(text);
          }}
        >
          送信
        </Button>
      </DialogActions>
    </>
  );
};
const useSimpleCustomFormDialog = () => {
  return useCustomFormDialog<undefined, string>({
    renderContent: ({ submit, cancel }) => {
      return (
        <>
          <DialogTitle>シンプルカスタムフォームダイアログ</DialogTitle>
          <SimpleCustomForm onSubmit={submit} onCancel={cancel} />
        </>
      );
    },
  });
};
useSimpleCustomFormDialogの呼び出し例
const Page: FC = () => {
  const { open, dialogElement } = useSimpleCustomFormDialog();

  return (
    <Box>
      <Button
        variant="contained"
        onClick={() => {
          open({
            onSubmit: (data) => {
              enqueueSnackbar(`${data}」を送信しました`, {
                variant: "success"
              });
            }
          });
        }}
      >
        開く
      </Button>
      {dialogElement}
    </Box>
  )
}
例: 非同期設定ダイアログ
非同期設定ダイアログを呼ぶhooks
type AsyncExecType = "success" | "fail";
const AsyncSettingCustomForm: FC<{
  processing: boolean;
  onSubmit: (data: { asyncExecType: AsyncExecType; waitTime: number }) => void;
  onCancel: () => void;
}> = ({ processing, onSubmit, onCancel }) => {
  const [waitTime, setWaitTime] = useState(1000);
  const [asyncExecType, setAsyncExecType] = useState<AsyncExecType>("success");

  return (
    <>
      <DialogContent>
        <FormControl>
          <FormLabel>非同期実行種別</FormLabel>
          <RadioGroup
            value={asyncExecType}
            row
            onChange={(event) => {
              setAsyncExecType(event.target.value as AsyncExecType);
            }}
          >
            <FormControlLabel
              value="success"
              control={<Radio />}
              label="成功"
            />
            <FormControlLabel value="fail" control={<Radio />} label="失敗" />
          </RadioGroup>
        </FormControl>
        <br />
        <TextField
          sx={{ mt: 1 }}
          value={waitTime}
          label="遅延時間"
          type="number"
          size="small"
          onChange={(event) => {
            const elInput = event.target;
            if (!(elInput instanceof HTMLInputElement)) {
              return;
            }

            setWaitTime(elInput.valueAsNumber);
          }}
        />
      </DialogContent>
      <DialogActions>
        <LoadingButton
          loading={processing}
          variant="outlined"
          onClick={onCancel}
        >
          キャンセル
        </LoadingButton>
        <LoadingButton
          loading={processing}
          variant="contained"
          onClick={() => {
            onSubmit({
              asyncExecType,
              waitTime,
            });
          }}
        >
          送信
        </LoadingButton>
      </DialogActions>
    </>
  );
};
const useAsyncSettingCustomFormDialog = () => {
  return useCustomFormDialog<
    undefined,
    {
      asyncExecType: AsyncExecType;
      waitTime: number;
    }
  >({
    renderContent: ({ processing, submit, cancel }) => {
      return (
        <>
          <DialogTitle>非同期設定ダイアログ</DialogTitle>
          <AsyncSettingCustomForm
            processing={processing}
            onSubmit={submit}
            onCancel={cancel}
          />
        </>
      );
    },
  });
};
useAsyncSettingCustomFormDialogの呼び出し例
const Page: FC = () => {
  const { open, dialogElement } = useAsyncSettingCustomFormDialog();

  return (
    <Box>
      <Button
        variant="contained"
        onClick={() => {
          open({
            onSubmit: (data) => {
              return new Promise((resolve, reject) => {
                window.setTimeout(() => {
                  switch (data.asyncExecType) {
                    case "success":
                      resolve();
                      enqueueSnackbar("成功しました", {
                        variant: "success",
                      });
                      return;
                    case "fail":
                      reject();
                      enqueueSnackbar("失敗しました。。", {
                        variant: "error",
                      });
                      return;
                  }
                }, data.waitTime);
              });
            },
          });
        }}
      >
        開く
      </Button>
      {dialogElement}
    </Box>
  );
};
例: 確認フォームダイアログ
確認フォームダイアログを呼ぶhooks
const ConfirmCustomForm: FC<{
  confirmText: string;
  processing: boolean;
  onSubmit: () => void;
  onCancel: () => void;
}> = ({ confirmText, processing, onSubmit, onCancel }) => {
  const [inputText, setInputText] = useState("");
  return (
    <>
      <DialogContent>
        <Box>
          確認のため「
          <Typography component="span" fontWeight="bold">
            {confirmText}
          </Typography>
          」と入力してください。
        </Box>
        <TextField
          value={inputText}
          fullWidth
          size="small"
          onChange={(event) => {
            setInputText(event.target.value);
          }}
        />
      </DialogContent>
      <DialogActions>
        <LoadingButton
          loading={processing}
          variant="outlined"
          onClick={onCancel}
        >
          キャンセル
        </LoadingButton>
        <LoadingButton
          loading={processing}
          variant="contained"
          color="error"
          disabled={inputText !== confirmText}
          onClick={onSubmit}
        >
          送信
        </LoadingButton>
      </DialogActions>
    </>
  );
};
const useConfirmCustomFormDialog = () => {
  return useCustomFormDialog<{ confirmText: string }>({
    renderContent: ({ props, processing, submit, cancel }) => {
      if (props == null) {
        return <></>;
      }
      return (
        <>
          <DialogTitle>確認フォームダイアログ</DialogTitle>
          <ConfirmCustomForm
            confirmText={props.confirmText}
            processing={processing}
            onSubmit={submit}
            onCancel={cancel}
          />
        </>
      );
    }
  });
};
useConfirmCustomFormDialogの呼び出し例
const Page: FC = () => {
  const { open, dialogElement } = useConfirmCustomFormDialog();

  return (
    <Box>
      <Button
        variant="contained"
        color="error"
        onClick={() => {
          open({
            props: {
              confirmText: "confirm"
            },
            onSubmit: () => {
              return new Promise((resolve) => {
                window.setTimeout(() => {
                  resolve();
                  enqueueSnackbar("送信しました", {
                    variant: "success"
                  });
                }, 1000);
              });
            }
          });
        }}
      >
        開く
      </Button>
      {dialogElement}
    </Box>
  )
}

検証コード

この記事で紹介したコードは以下のCodeSandboxにありますので、動きや詳細のコードを確認したい方はご覧ください。

終わりに

以上がカスタム確認ダイアログをReact hooksで実装する方法でした。カスタムUI部分もhooks側に閉じ込めて呼び出すだけでOK時の処理を設定できるようになったのでアプリケーション側が相当スッキリすると思いました。更に確認ダイアログが色々な画面で使われる場合は共通のものを使いやすくなったと思いました。
確認ダイアログだけではなく、更に拡張することでフォームダイアログについてもhooks側に切り出すことができ、相当汎用性が高くなったなと感じました。ただその分useCustomFormDialogが複雑になりすぎて初見だと詳細の流れが理解しづらかったり、ボタンクリック時のロジックが長くなりすぎる可能性があるので状況に応じて使い分けていけると良いかなと思いました。

GitHubで編集を提案

Discussion