react-confirmの実装の中身を調べてみた
始めに
以前 以下の記事でwindow.confirm
のようにメソッドを実行して確認ダイアログが表示されるようなカスタムhooksを作りました。
こちらはかなり柔軟性の高いもので内容自体は良いのですが、dialog表示用のReactElementをhooksから返してそれをrenderさせなければいけない関係上、コンポーネントの中でしか使えない問題がありました。
改めてReactのconfirmライブラリを探してみましたが、react-confirm
が非常に柔軟性高くカスタマイズできて、しかもconfirm
とメソッド一つで確認ダイアログを表示することができ、なぜこんなことができるか気になったので実装の中身を調べてみました。
react-confirmの使い方
まずは以前作ったこのパターンをreact-confirm
で実装したいと思います。
基本実装
確認用ダイアログを作る
始めにダイアログコンポーネントを用意します。ConfirmDialog
という型の第一引数にconfirm実行時に渡すprops、第二引数にレスポンスの型を設定します。非同期処理中にローディングを出すには自前でフラグを管理する必要があったので、okHandler
を渡すようにしています。show
やproceed
はconfirmable
でラップする時に渡される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メソッドの完成です。
import { createConfirmation } from 'react-confirm';
export const confirm = createConfirmation(ConfirmableDialog);
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);
createConfirmationInMountPoint
はcreateConfirmation
とインターフェースは同じなので、これの代わりに使ってconfirmメソッドを作ります。
-export const confirm = createConfirmation(ConfirmableDialog);
+export const confirm = createConfirmationInMountPoint(ConfirmableDialog);
後はMountPoint
をThemeProvider
の中に配置したら完了です。
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の橋渡し用のものでした。
createConfirmation
はcreateConfirmationCreater
から作られたものですが、これはmounter
を事前に紐づけたものになります。
ここは関数のネストが多すぎるのと型がなくて非常に読みづらかったので、それぞれ変数名を与えて、ついでにTypeScriptに書き直したものを以下に示します。mounter
の中はcreateConfirmation
を返すようになっており、更にその中はconfirm
メソッドが返っています。confirm
メソッドの中でmounter
経由でマウント、アンマウントをしていることが分かります。マウント時に渡すコンポーネントはconfirmable
でラップされていることが前提でreject
, resolve
, dispose
が渡されています。
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;
};
なお、createConfirmation
はcreateDomTreeMounter()
をmounterとして渡して作られたものでした。mounterに関する話は次のセクションで話したいと思います。
マウント先の指定
マウント先の指定はDOMツリーに対するもの(createDomTreeMounter
)とReactツリーに対するもの(createReactTreeMounter
)があったのでそれぞれについて説明します。
createDomTreeMounter
DOMツリーの場合は以下のようなコードになりますが、これは意外と単純な実装をしていて、新しくdivタグを作って、そこにcreateRoot
で新しくReactをrenderしていました。divタグの追加先を引数で指定することが可能で、未指定の場合はdocument.body
の配下に追加されます。renderしたものはアンマウント時に削除しておく必要があるのでconfirms
変数に保存しているようです。(ちなみにremoveChildでDOMだけ削除していますが、root.unmount
も必要なのでは?とちょっと思いました)
この実装方法だと既存のReactアプリから切り離されていますが、confirmメソッド実行時ではそもそもReactのライフサイクルから切り離されている状態なので確かに別でrenderしちゃっても良いのかと思いました。ただReactコンテキストは当然共有されないので、共通のコンテキストを使いたい場合は次に説明するReactTree上に配置される方法にする必要があります。
createReactTreeMounter
ReactTree上に配置するパターンもcreateDomTreeMounter
とコードの大枠はほとんど同じですが、options
のところにsetMountedCallback
が含まれているのがポイントです。このメソッドではcallbacks.mounted
にコールバック関数を登録する処理が書かれており、マウントやアンマウント時に呼び出されるようにしています。
アンマウント時でもcallbacks.mounted
が呼ばれるのは違和感かと思いますが、それはcreateMountPoint
でマウント用のコンポーネントを作成するコードを見ると分かります。この引数に先ほどのcreateReactTreeMounter
で作られたmounterを渡すことでreactTreeMounter.options.setMountedCallback
経由でコールバック関数をuseEffectの中で設定することができます。これによってmounterの方でマウント、アンマウントが実行されるたびにuseEffect内で設定したコールバックが実行され、rerenderできるようになります。要はグローバルなオブジェクトにリスナーを設定できる枠を用意しておいて、そこにリスナーを登録しておくことで外からReactのライフサイクルに手を入れられるようにしていました。 この仕組みができれば後は好きな場所にMountPoint
を配置するだけで同じReactアプリ上で動かすことができます。
react-confirmに似たライブラリ、react-callについて
react-confirm
は設計としては非常に柔軟性が高いもので良かったのですが、コードリーディグをしっかりやると流石に過剰すぎるところだったり、ちょっと考慮漏れしていないかな?と気になるところがありました。そもそもTypeScriptで書かれていないのも痛いところです。
もう少し他のライブラリを調べていたところ、最近react-call
というライブラリが出てきたようで、これも似たような仕組みで作られていました。最近作られただけあってモダンな実装になっており、結構良さそうな印象でした。以下の記事で絶賛されています😊
コードはcreateCallable
一つだけで、react-confirm
で言うcreateReactTreeMounter
やcreateMountPoint
を全部実行して一つのオブジェクトを返すという作りになっていました。なお、react-confirm
でいうcreateDomTreeMounter
を使ったパターンは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だったのでここでは同じ値にした)
};
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider theme={theme}>
{/* 他の設定は省略 */}
<App />
- <MountPoint />
+ <Confirm.Root />
</ThemeProvider>
</StrictMode>
);
<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で確認ダイアログで何を使えば良いと悩んでいる方や、ダイアログライブラリの実装の中身が知りたい方の参考になれば幸いです。
Discussion