🔦

react-confirmの実装の中身を調べてみた

に公開

始めに

以前 以下の記事でwindow.confirmのようにメソッドを実行して確認ダイアログが表示されるようなカスタムhooksを作りました。

https://zenn.dev/numa_san/articles/e749a6d084955a

こちらはかなり柔軟性の高いもので内容自体は良いのですが、dialog表示用のReactElementをhooksから返してそれをrenderさせなければいけない関係上、コンポーネントの中でしか使えない問題がありました。
改めてReactのconfirmライブラリを探してみましたが、react-confirmが非常に柔軟性高くカスタマイズできて、しかもconfirmとメソッド一つで確認ダイアログを表示することができ、なぜこんなことができるか気になったので実装の中身を調べてみました。

react-confirmの使い方

まずは以前作ったこのパターンをreact-confirmで実装したいと思います。

https://zenn.dev/numa_san/articles/e749a6d084955a#確認ダイアログ内でフォーム入力に対応する

基本実装

確認用ダイアログを作る

始めにダイアログコンポーネントを用意します。ConfirmDialogという型の第一引数にconfirm実行時に渡すprops、第二引数にレスポンスの型を設定します。非同期処理中にローディングを出すには自前でフラグを管理する必要があったので、okHandlerを渡すようにしています。showproceedconfirmableでラップする時に渡されるpropsで、これらを使ってダイアログの表示やconfirmの確定を伝えます。okHandlerでOK時の処理を書いているのでproceedには何も渡さなくても問題はないですが一応booleanにしてOKの時だけtrueを渡すようにしています。
後は作成したコンポーネントをconfirmableでラップすることで確認用ダイアログが完成します。

確認用ダイアログの作成
import { useState } from 'react';
import { ConfirmDialog, confirmable } from 'react-confirm';
import {
  Dialog,
  DialogContent,
  DialogActions,
  Box,
  Typography,
  TextField,
  Button,
} from '@mui/material';

type ConfirmProps = {
  confirmText: string;
  okHandler: () => Promise<void>;
};

const Confirmation: ConfirmDialog<ConfirmProps, boolean> = ({
  show,
  proceed,
  confirmText,
  okHandler,
}) => {
  const [inputText, setInputText] = useState('');
  const [isProcessing, setIsProcessing] = useState(false);

  return (
    <Dialog
      open={show}
      onClose={() => {
        proceed(false);
      }}
    >
      {/* 今回はタイトルを出す必要がないのでコメントアウト */}
      {/* <DialogTitle>確認フォーム付きダイアログ</DialogTitle> */}
      <DialogContent>
        <Box>
          確認のため「
          <Typography component="span" fontWeight="bold">
            {confirmText}
          </Typography>
          」と入力してください。
        </Box>
        <TextField
          value={inputText}
          fullWidth
          size="small"
          onChange={(event) => {
            setInputText(event.target.value);
          }}
        />
      </DialogContent>
      <DialogActions>
        <Button
          loading={isProcessing}
          variant="outlined"
          onClick={() => {
            proceed(false);
          }}
        >
          キャンセル
        </Button>
        <Button
          loading={isProcessing}
          variant="contained"
          color="primary"
          disabled={inputText !== confirmText}
          onClick={() => {
            setIsProcessing(true);
            okHandler()
              .then(() => {
                proceed(true);
              })
              .catch(() => {
                setIsProcessing(false);
              });
          }}
        >
          OK
        </Button>
      </DialogActions>
    </Dialog>
  );
};

const ConfirmableDialog = confirmable(Confirmation)

confirmメソッドを作成

後はcreateConfirmationで確認用ダイアログを渡したらconfirmメソッドの完成です。

confirmメソッドを作成
import { createConfirmation } from 'react-confirm';

export const confirm = createConfirmation(ConfirmableDialog);

confirmメソッドの呼び出し

confirmメソッドは例えば以下のように呼び出します。

confirmメソッドの呼び出し例
<Button
  variant="contained"
  color="primary"
  onClick={() => {
    confirm({
      confirmText: 'confirm',
      okHandler: () => {
        return new Promise((resolve) => {
          window.setTimeout(() => {
            resolve();
            enqueueSnackbar('実行しました', {
              variant: 'success',
            });
          }, 1000);
        });
      },
    });
  }}
>
  確認
</Button>

とても簡単にできてしまいましたが、一つ問題があります。今の書き方の場合は別アプリでReactを起動しているような作りになっているため、MUIのThemeProviderが渡されておらず、例えばprimaryの色を変えたのにダイアログの方が初期の色で表示されてしまう問題が発生します。

これを解決するには確認ダイアログのマウント先を指定する必要があり、それを次のセクションで説明します。

確認ダイアログのマウント先を指定する

確認ダイアログのマウント先を指定するには、createReactTreeMounterを使ってマウント用インスタンスを生成して、これを使って確認メソッド生成メソッドとマウント用コンポーネントを作ります。

マウント先を指定できるように準備
import {
  createConfirmationCreater,
  createReactTreeMounter,
  createMountPoint,
} from 'react-confirm';

const mounter = createReactTreeMounter();

export const createConfirmationInMountPoint =
  createConfirmationCreater(mounter);

export const MountPoint = createMountPoint(mounter);

createConfirmationInMountPointcreateConfirmationとインターフェースは同じなので、これの代わりに使ってconfirmメソッドを作ります。

-export const confirm = createConfirmation(ConfirmableDialog);
+export const confirm = createConfirmationInMountPoint(ConfirmableDialog);

後はMountPointThemeProviderの中に配置したら完了です。

MountPointを配置
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ThemeProvider theme={theme}>
      {/* 他の設定は省略 */}
      <App />
      <MountPoint />
    </ThemeProvider>
  </StrictMode>
);

検証コード

これまでの実装をStackBlitzで書きましたので、動作確認したい方は以下からご確認ください。こちらではマウント先指定ありとなしそれぞれのパターンを確認することができます。

react-confirmの仕組み

ここからはreact-confirmがどういう仕組みで動いているかコードを読んで、要所要所でやっていることをまとめました。なお、react-confirmはJavaScriptで書かれていて型情報がなくリーディングが大変だったのでTypeScriptに書き起こしてから読むことにしました。TypeScript化は以下でやったので興味がある方はこちらのコードを見るとライブラリコードの全容はより分かりやすいかもしれません。

確認ダイアログの生成

まずはconfirmableですが、これはいわゆるHOCパターンで、次に説明するcreateConfirmationで渡すpropsの橋渡し用のものでした。

https://github.com/haradakunihiko/react-confirm/blob/v0.3.0/src/confirmable.js

createConfirmationcreateConfirmationCreaterから作られたものですが、これはmounterを事前に紐づけたものになります。

https://github.com/haradakunihiko/react-confirm/blob/v0.3.0/src/createConfirmation.js#L3-L29

ここは関数のネストが多すぎるのと型がなくて非常に読みづらかったので、それぞれ変数名を与えて、ついでにTypeScriptに書き直したものを以下に示します。mounterの中はcreateConfirmationを返すようになっており、更にその中はconfirmメソッドが返っています。confirmメソッドの中でmounter経由でマウント、アンマウントをしていることが分かります。マウント時に渡すコンポーネントはconfirmableでラップされていることが前提でreject, resolve, disposeが渡されています。

createConfirmationCreaterをTypeScriptで実装
export const createConfirmationCreater = (mounter: Mounter) => {
  const createConfirmation = <P, R>(
    Component: ConfirmableDialog<P, R>,
    unmountDelay = 1000,
    mountingNode?: HTMLElement
  ) => {
    const confirm = (props: P) => {
      let mountId: string;

      const dispose = () => {
        setTimeout(() => {
          mounter.unmount(mountId);
        }, unmountDelay);
      };

      const promise = new Promise<R>((resolve, reject) => {
        try {
          mountId = mounter.mount(
            Component,
            { reject, resolve, dispose, ...props },
            mountingNode
          );
        } catch (e) {
          console.error(e);
          // NOTE: そのまま上に投げるならわざわざcatchしなくて良さそう
          throw e;
        }
      });

      return promise.then(
        (result) => {
          dispose();
          return result;
        },
        (reason) => {
          dispose();
          return Promise.reject(reason);
        }
      );
    };
    return confirm;
  };
  return createConfirmation;
};

なお、createConfirmationcreateDomTreeMounter()をmounterとして渡して作られたものでした。mounterに関する話は次のセクションで話したいと思います。

https://github.com/haradakunihiko/react-confirm/blob/v0.3.0/src/createConfirmation.js#L31

マウント先の指定

マウント先の指定はDOMツリーに対するもの(createDomTreeMounter)とReactツリーに対するもの(createReactTreeMounter)があったのでそれぞれについて説明します。

createDomTreeMounter

DOMツリーの場合は以下のようなコードになりますが、これは意外と単純な実装をしていて、新しくdivタグを作って、そこにcreateRootで新しくReactをrenderしていました。divタグの追加先を引数で指定することが可能で、未指定の場合はdocument.bodyの配下に追加されます。renderしたものはアンマウント時に削除しておく必要があるのでconfirms変数に保存しているようです。(ちなみにremoveChildでDOMだけ削除していますが、root.unmountも必要なのでは?とちょっと思いました)

https://github.com/haradakunihiko/react-confirm/blob/v0.3.0/src/mounter/domTree.js

この実装方法だと既存のReactアプリから切り離されていますが、confirmメソッド実行時ではそもそもReactのライフサイクルから切り離されている状態なので確かに別でrenderしちゃっても良いのかと思いました。ただReactコンテキストは当然共有されないので、共通のコンテキストを使いたい場合は次に説明するReactTree上に配置される方法にする必要があります。

createReactTreeMounter

ReactTree上に配置するパターンもcreateDomTreeMounterとコードの大枠はほとんど同じですが、optionsのところにsetMountedCallbackが含まれているのがポイントです。このメソッドではcallbacks.mountedにコールバック関数を登録する処理が書かれており、マウントやアンマウント時に呼び出されるようにしています。

https://github.com/haradakunihiko/react-confirm/blob/v0.3.0/src/mounter/reactTree.js#L4-L29

アンマウント時でもcallbacks.mountedが呼ばれるのは違和感かと思いますが、それはcreateMountPointでマウント用のコンポーネントを作成するコードを見ると分かります。この引数に先ほどのcreateReactTreeMounterで作られたmounterを渡すことでreactTreeMounter.options.setMountedCallback経由でコールバック関数をuseEffectの中で設定することができます。これによってmounterの方でマウント、アンマウントが実行されるたびにuseEffect内で設定したコールバックが実行され、rerenderできるようになります。要はグローバルなオブジェクトにリスナーを設定できる枠を用意しておいて、そこにリスナーを登録しておくことで外からReactのライフサイクルに手を入れられるようにしていました。 この仕組みができれば後は好きな場所にMountPointを配置するだけで同じReactアプリ上で動かすことができます。

https://github.com/haradakunihiko/react-confirm/blob/v0.3.0/src/mounter/reactTree.js#L31-L55

react-confirmに似たライブラリ、react-callについて

react-confirmは設計としては非常に柔軟性が高いもので良かったのですが、コードリーディグをしっかりやると流石に過剰すぎるところだったり、ちょっと考慮漏れしていないかな?と気になるところがありました。そもそもTypeScriptで書かれていないのも痛いところです。
もう少し他のライブラリを調べていたところ、最近react-callというライブラリが出てきたようで、これも似たような仕組みで作られていました。最近作られただけあってモダンな実装になっており、結構良さそうな印象でした。以下の記事で絶賛されています😊

https://zenn.dev/ykicchan/articles/5415871c017b22

コードはcreateCallable一つだけで、react-confirmで言うcreateReactTreeMountercreateMountPointを全部実行して一つのオブジェクトを返すという作りになっていました。なお、react-confirmでいうcreateDomTreeMounterを使ったパターンはreact-callには無いため、必ずマウント先を設定しておく必要があります。

https://github.com/desko27/react-call/blob/v1.7.0/lib/createCallable/index.tsx

今回のサンプルコードをreact-callで書き換えると以下のようになって、使い方はほぼ同じということが分かると思います。

react-callを使った確認ダイアログの作成
 import { useState } from 'react';
-import { ConfirmDialog, confirmable } from 'react-confirm';
+import { createCallable } from 'react-call';
 import {
   Dialog,
   DialogContent,
   DialogActions,
   Box,
   Typography,
   TextField,
   Button,
 } from '@mui/material';

 type ConfirmProps = {
   confirmText: string;
   okHandler: () => Promise<void>;
 };

-const Confirmation: ConfirmDialog<ConfirmProps, boolean> = ({
-  show,
-  proceed,
-  confirmText,
-  okHandler,
-}) => {
+export const Confirm = createCallable<ConfirmProps, boolean>(
   ({ call, confirmText, okHandler }) => {
     const [inputText, setInputText] = useState('');
     const [isProcessing, setIsProcessing] = useState(false);

     return (
       <Dialog
-        open={show}
+        open={!call.ended}
         onClose={() => {
-          proceed(false);
+          call.end(false);
         }}
       >
         <DialogContent>
           <Box>
             確認のため「
             <Typography component="span" fontWeight="bold">
               {confirmText}
             </Typography>
             」と入力してください。
           </Box>
           <TextField
             value={inputText}
             fullWidth
             size="small"
             onChange={(event) => {
               setInputText(event.target.value);
             }}
           />
         </DialogContent>
         <DialogActions>
           <Button
             loading={isProcessing}
             variant="outlined"
             onClick={() => {
-              proceed(false);
+              call.end(false);
             }}
           >
             キャンセル
           </Button>
           <Button
             loading={isProcessing}
             variant="contained"
             color="primary"
             disabled={inputText !== confirmText}
             onClick={() => {
               setIsProcessing(true);
               okHandler()
                 .then(() => {
-                  proceed(true);
+                  call.end(true);
                 })
                 .catch(() => {
                   setIsProcessing(false);
                 });
             }}
           >
             OK
           </Button>
         </DialogActions>
       </Dialog>
     );
   },
+  1000 // 破棄するまで待つ時間(react-confirmではデフォルトで1000msだったのでここでは同じ値にした)
 };
react-callを使ったMountPointの配置
 createRoot(document.getElementById('root')!).render(
   <StrictMode>
     <ThemeProvider theme={theme}>
       {/* 他の設定は省略 */}
       <App />
-      <MountPoint />
+      <Confirm.Root />
     </ThemeProvider>
   </StrictMode>
 );
react-callを使ったconfirmメソッドの呼び出し例
 <Button
   variant="contained"
   color="primary"
   onClick={() => {
-    confirm({
+    Confirm.call({
       confirmText: 'confirm',
       okHandler: () => {
         return new Promise((resolve) => {
           window.setTimeout(() => {
             resolve();
             enqueueSnackbar('実行しました', {
               variant: 'success',
             });
           }, 1000);
         });
       },
     });
   }}
 >
   確認
 </Button>

参考としてreact-callで書いたパターンも用意しましたので、興味がある方はこちらもご参照ください。

終わりに

以上がreact-confirmの実装の中身でした。createRootを使って別な場所で改めてReactのrenderをしてしまう発想は目から鱗で、疎結合な設計としては完璧だなと思いました。コンテキストも絡ませたいケースも考えられていて非常に勉強になりました。
react-confirmは細かいところの実装で気になるところがあったりTypeScriptで書かれておらず、更にメンテもあまりされていなさそうなことから別なライブラリとしてreact-callを紹介しました。こちらも設計の大枠はほとんど同じなので後継としては良さそうですが、mounterを共通で使用できないためカスタムダイアログの数だけMountPointを設定しなければならないというちょっとしたデメリットはありそうでした。コンテキストを共有しなくても問題ないケースだとreact-confirmの方が気軽に定義できるかもしれないです。とはいえあちらはあちらでラップが結構多くてややこしいかもですが(汗)。
Reactで確認ダイアログで何を使えば良いと悩んでいる方や、ダイアログライブラリの実装の中身が知りたい方の参考になれば幸いです。

GitHubで編集を提案

Discussion