4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Reactでモーダルダイアログ Promise方式

Last updated at Posted at 2021-06-14

#概要

良くあるOK/Cancel確認ダイアログをReactで実装する場合、
ダイアログを表示している/していない、OK押された時のハンドラをStateで管理するのがReact的。
ただ、こういう場合はwindow.confirmのように手続き的なコーディングの方が理解しやすかったりするのでそれをPromiseで表現する。
汎用化してモーダルダイアログ全般に対応。

#方針

  1. ダイアログはルート的な要素で管理してコンテキスト経由でダイアログ呼び出しの非同期関数をダイアログを利用するコンポーネントに渡したい。
  2. 呼び出し側はawaitすることで手続き的に記述可能。
  3. ダイアログコンポーネントは自由に指定可能。UIには関知しない。モーダルの管理機構のみ提供。
  4. どうせなら汎用的にしてみる。コンテキスト使わずrender propsで関数を渡す方式も対応。
  5. ダイアログのインプット、アウトプットの型を指定可能。TypeScriptでタイプセーフ。
  6. モーダルは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が大変だな。
でもリファクタしやすい。

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?