#概要
良くあるOK/Cancel確認ダイアログをReactで実装する場合、
ダイアログを表示している/していない、OK押された時のハンドラをStateで管理するのがReact的。
ただ、こういう場合はwindow.confirmのように手続き的なコーディングの方が理解しやすかったりするのでそれをPromiseで表現する。
汎用化してモーダルダイアログ全般に対応。
#方針
- ダイアログはルート的な要素で管理してコンテキスト経由でダイアログ呼び出しの非同期関数をダイアログを利用するコンポーネントに渡したい。
- 呼び出し側はawaitすることで手続き的に記述可能。
- ダイアログコンポーネントは自由に指定可能。UIには関知しない。モーダルの管理機構のみ提供。
- どうせなら汎用的にしてみる。コンテキスト使わずrender propsで関数を渡す方式も対応。
- ダイアログのインプット、アウトプットの型を指定可能。TypeScriptでタイプセーフ。
- モーダルはtransitionが入って描画コストが高かったりするので、モーダルの状態変更時に子コンポーネントのrenderが走らないようにチューニング。
#実装
親コンポーネントでConfirmダイアログを子コンポートに提供
App.tsx
import React from 'react';
import { WithPromiseModal } from 'src/WithPromiseModal';
import MyComponent from 'src/MyComponent';
import ConfirmModal from 'src/ConfirmModal';
import { ConfirmParam, ConfirmContext } from 'src/contexts';
function App() {
return (
<WithPromiseModal
initialParam={{ title: '', message: '' }} // モーダルオープンしていない場合のshowModalParam
// モーダルの状態が変わる度に呼ばれる。モーダルをrenderする。
renderModal={({
modalState: { show, handleOk, handleCancel }, // モーダルの状態
// モーダルオープン関数のパラメータ(Contextからの型推論が効く)
showModalParam: { title, message },
}) => {
return (
<ConfirmModal
show={show}
handleOk={handleOk}
handleCancel={handleCancel}
title={title}
message={message}
/>
);
}}
context={ConfirmContext} // モーダルオープン関数をこのコンテキストでプロバイドする
>
<MyComponent />
</WithPromiseModal>
);
}
export default App;
コンテキストの生成
contexts.tsx
import React, { useContext } from 'react';
// コンテキストで渡すモーダルオープン関数の引数の型
export type ConfirmParam = {
title: string;
message: string;
};
export const ConfirmContext = React.createContext(
async (param: ConfirmParam) => {}
);
export function useConfirm() {
return useContext(ConfirmContext);
}
子コンポーネントで利用
MyComponent.tsx
import React, { useState, useEffect } from 'react';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
import { useConfirm } from 'src/contexts';
import { WithPromiseModal, ModalState } from 'src/WithPromiseModal';
type ModalInput = {
initialSelectedValue: string;
};
type ModalOutput = {
selectedValue: string;
};
const MyComponent = () => {
// コンテキストからconfirm関数を取得するHook
const confirm = useConfirm();
const handleClick = () => {
confirm({ title: '確認', message: '本気ですか?' })
.then(() => {
// はいが押された
console.log('はい');
})
.catch(() => {
// いいえが押された
console.log('いいえ');
});
};
return (
<>
<Button onClick={() => handleClick()}>とうろく</Button>
<Content />
</>
);
};
// Contextを使わないパターンのテスト
const Content = () => {
return (
<WithPromiseModal<ModalInput, ModalOutput>
initialParam={{ initialSelectedValue: '' }}
renderModal={({
modalState: { show, handleOk, handleCancel },
showModalParam, // 型はModalInput
}) => {
return (
<MyModal
show={show}
handleOk={handleOk}
handleCancel={handleCancel}
initialSelectedValue={showModalParam.initialSelectedValue}
/>
);
}}
// Contextを使わずrender propsでモーダルオープン関数を渡すパターン
render={({ showModal }) => {
// showModal関数のパラメータの型はModalOutput
return <MyContent showModal={showModal} />;
}}
/>
);
};
type MyContentProps = {
showModal: (param: ModalInput) => Promise<ModalOutput>;
};
const MyContent: React.FC<MyContentProps> = ({ showModal }) => {
console.log('MyContent render');
const handleClick = () => {
showModal({ initialSelectedValue: 'B' })
.then(({ selectedValue }) => {
console.log('selected', selectedValue);
})
.catch(() => {
console.log('not selected');
});
};
return (
<div>
<Button onClick={() => handleClick()}>おーぷん</Button>
</div>
);
};
type MyModalProps<T = ModalOutput> = ModalState<T> & {
initialSelectedValue: string;
};
const MyModal: React.FC<MyModalProps> = ({
show,
handleOk,
handleCancel,
initialSelectedValue: param,
}) => {
const [selectedValue, setSelectedValue] = useState('');
useEffect(() => {
setSelectedValue(param);
}, [param]);
return (
<>
<Modal show={show} onHide={handleCancel}>
<Modal.Header closeButton>
<Modal.Title>もーだる</Modal.Title>
</Modal.Header>
<Modal.Body>
<select
value={selectedValue}
onChange={(e) => setSelectedValue(e.target.value)}
>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
</select>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleCancel}>
きゃんせる
</Button>
<Button
variant="primary"
onClick={() => handleOk({ selectedValue: selectedValue })}
>
せんたく
</Button>
</Modal.Footer>
</Modal>
</>
);
};
export default MyComponent;
モーダル管理コンポーネント
import React, { useState, useCallback, useMemo } from 'react';
export type ModalState<T> = {
show: boolean;
handleOk: (param: T) => void;
handleCancel: () => void;
};
type RenderModalParam<I, O> = {
modalState: ModalState<O>;
showModalParam: I; // 関数呼び出し時のパラメータ
};
type RenderModal<I, O> = (
param: RenderModalParam<I, O>
) => React.ReactElement<any, any>;
type RenderParam<I, O> = {
showModal: (param: I) => Promise<O>;
};
type Render<I, O> = (
param: RenderParam<I, O>
) => React.ReactElement<any, any> | null;
type Props<I, O> = {
initialParam: I;
renderModal: RenderModal<I, O>;
context?: React.Context<(params: I) => Promise<O>>;
children?: React.ReactNode;
render?: Render<I, O>;
};
export function WithPromiseModal<I, O = void>({
initialParam,
renderModal,
children,
context,
render,
}: Props<I, O>): React.ReactElement {
const [show, setShow] = useState(false);
const [param, setParam] = useState<I>(initialParam);
// 関数をStateに設定する時は () => 設定したい関数
const [handleOk, setHandleOk] = useState(() => () => {});
const [handleCancel, setHandleCancel] = useState(() => () => {});
// ダイアログで良くあるtransitionは結構重いから
// useCallbackを使用してダイアログのState変更時に利用する側のrenderが走らないようにしておく
const showModal = useCallback((param: I) => {
return new Promise<O>((resolve, reject) => {
console.log('confirm called');
setShow(true);
setParam(param);
setHandleOk(() => (param: O) => {
setShow(false);
resolve(param);
});
setHandleCancel(() => () => {
setShow(false);
reject();
});
});
}, []);
const memorizedRender = useMemo(() => {
return render ? render({ showModal }) : null;
}, [render, showModal]);
return (
<>
{context ? (
<context.Provider value={showModal}>{children}</context.Provider>
) : (
children
)}
{memorizedRender}
{renderModal({
modalState: { show, handleOk, handleCancel },
showModalParam: param,
})}
</>
);
}
Confirmダイアログ
ConfirmModal.tsx
import React from 'react';
import Modal from 'react-bootstrap/Modal';
import Button from 'react-bootstrap/Button';
import { ModalState } from 'src/WithPromiseModal';
import { ConfirmParam } from 'src/contexts';
type Props<T = void> = ModalState<T> & ConfirmParam;
const ConfirmModal: React.FC<Props> = ({
show,
handleOk,
handleCancel,
title,
message,
}) => {
return (
<>
<Modal show={show} onHide={() => handleCancel()}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => handleCancel()}>
いいえ
</Button>
<Button variant="primary" onClick={() => handleOk()}>
はい
</Button>
</Modal.Footer>
</Modal>
</>
);
};
export default ConfirmModal;
#所感
汎用的な共通機構を作るとTypeScriptが大変だな。
でもリファクタしやすい。