nakamurakko’s blog

仕事で覚えたこと、勉強したことを自分のメモ代わりに書いていこうかなと。

React Bootstrap - Modal の戻り値の受け取り方を考える

環境

  • React 19.0
  • React Bootstrap 2.10
  • Bootstrap 5.3

React BootstrapModal を使って、モーダルダイアログ画面からの値を受け取る方法について考えてみた。ソースはこちら

標準の書き方

React Bootstrap の Modal を使って、

  1. モーダルダイアログ画面を呼び出し。
  2. モーダルダイアログ画面で選択して、選択結果を返す。

を実装する場合、 React Bootstrap のサンプル通り props を使うだろう。

例えば、モーダルダイアログ側は下記のように実装した。

import React, { useState } from 'react';
import { Button, Form, Modal } from 'react-bootstrap';

/**
 * Modal sample dialog 1
 */
export default function ModalSampleDialog1({ showDialog, onSelect }:
  {
    /** Modal ダイアログを表示するかどうか。 */
    showDialog: boolean,
    /**
     * 選択時イベント。
     * @param selectedFruit 選択した値。
     */
    onSelect: (selectedFruit: string) => void
  }
): React.JSX.Element {

  const [selectedFruit, setSelectedFruit] = useState<string>('');

  /**
   * Modal 表示時の処理。
   */
  const handleShow = (): void => {
    setSelectedFruit('');
  };

  return (
    <Modal
      show={showDialog}
      onShow={handleShow}
    >
      <Modal.Header>
        <Modal.Title>Modal Sample dialog 1</Modal.Title>
      </Modal.Header>
      <Modal.Body>
        <Form>
          <Form.Check
            label='Apple'
            name='fruit'
            type='radio'
            value='Apple'
            onChange={(event): void => setSelectedFruit(event.target.value)}
          />
          <Form.Check
            label='Banana'
            name='fruit'
            type='radio'
            value='Banana'
            onChange={(event): void => setSelectedFruit(event.target.value)}
          />
        </Form>
      </Modal.Body>
      <Modal.Footer>
        <Button onClick={(): void => onSelect(selectedFruit)}>Select</Button>
      </Modal.Footer>
    </Modal>
  );
}

props に用意した showDialogModal.show プロパティに渡して、 true だった場合にモーダルダイアログ表示する。その後、 Select ボタンクリック時に onSelect を実行して、結果を呼び出し側に渡している。

呼び出し側では下記の実装を追加した。

import React, { useState } from 'react';
import { Button, Col, Container, Row } from 'react-bootstrap';

import ModalSampleDialog1 from './ModalSampleDialog1';

/**
 * Modal sample 1
 */
export default function ModalSample1(): React.JSX.Element {

  const [showDialog, setShowDialog] = useState<boolean>(false);
  const [selectedFruit, setSelectedFruit] = useState<string>('');

  /**
   * ダイアログを表示する。
   */
  const handleShowDialog = (): void => setShowDialog(true);

  /**
   * 選択ボタンをクリックした時の処理。
   * @param selectedFruit 選択した値。
   */
  const handleSelect = (selectedFruit: string): void => {
    setSelectedFruit(selectedFruit);
    setShowDialog(false);
  };

  return (
    <div>
      <h2>Modal Sample 1</h2>
      <Container fluid>
        <Row>
          <Col>
            <Button onClick={handleShowDialog}>Show dialog</Button>
            <label>{selectedFruit}</label>
          </Col>
        </Row>
      </Container>

      <ModalSampleDialog1
        showDialog={showDialog}
        onSelect={handleSelect}
      />
    </div>
  );

}

handleShowDialog でモーダルダイアログを表示させるために、呼び出し側の showDialog に true を設定して、 handleSelect で選択結果を受け取る。 この書き方の場合、

  • 表示・非表示の状態を呼び出し側に持っている。
  • 呼び出し側で表示用メソッド、選択結果受け取り(モーダルを閉じる処理含む)メソッドをそれぞれ用意する必要がある。

となるので、

  • 呼び出し側はモーダルダイアログを起動するメソッドを呼び出せて、表示・非表示の状態を持たない方法。
  • 呼び出し側で、モーダルダイアログ起動→選択結果の受け取りを1か所に書く方法。

を考えてみた。

コールバック関数

まずはコールバック関数で検討した。コールバック関数であれば呼び出し側は下記のように1か所に書ける。

const handleShowDialog = (): void => {
  モーダルダイアログ表示メソッド呼び出し((value) => {
    valueを設定。
  });
};

これを解決するために useImperativeHandle(と forwardRef)を使用した。まず、公開したいメソッド showDialog(引数 resultFunction はコールバック関数) を持つインターフェイスを定義する。

export interface ModalSampleDialog2Ref {
  /**
   * ダイアログを表示する。
   * @param resultFunction 表示終了後に実行する関数。
   */
  showdDialog: (resultFunction?: ResultFunction) => void;
}

type ResultFunction = (value: string) => void;

上記インターフェイスを指定した forwardRef を定義して、

const ModalSampleDialog2 = forwardRef<ModalSampleDialog2Ref>((props, ref) => {
  ...
}

useImperativeHandle を呼び出す。 useImperativeHandle の中に showDialog を追加し、下記のように Ref インターフェイスに定義したメソッドを実装して、引数 resultFunction をモーダルダイアログコンポーネント内に定義した useRef オブジェクトにコールバック関数の参照を渡す。

useImperativeHandle(ref, () => ({

  showdDialog: (resultFunction?: ResultFunction): void => {
    setSelectedFruit('');
    resultFunctionRef.current = resultFunction;
    setShowSelf(true);
  }

}));

表示開始をモーダルダイアログの外から呼び出せるメソッド showDialog に出来た事で、 Modal.show プロパティを切り替えるタイミングを呼び出し側のプロパティではなく、モーダルダイアログコンポーネント自身で管理できるようになった。

const [showSelf, setShowSelf] = useState<boolean>(false);

return (
  <Modal show={showSelf}>
    ...
  </Modal>
);

Select ボタンクリックは

  • 自身を表示にするプロパティに false を設定。
  • コールバック関数が参照できる場合は実行する。

という実装にして、選択結果を返すようにした。

const handleSelect = (): void => {
  setShowSelf(false);
  if (resultFunctionRef.current) {
    resultFunctionRef.current(selectedFruit);
  }
};

呼び出し側では useRef で参照できるようにして、

const modalSampleDialog2Ref = useRef<ModalSampleDialog2Ref>(null);

<ModalSampleDialog2 ref={modalSampleDialog2Ref} />

showdDialog を呼び出してモーダルダイアルを表示し、コールバックで結果を受け取れる。

const handleShowDialog = (): void => {
  modalSampleDialog2Ref.current?.showdDialog((value) => {
    setSelectedFruit(value);
  });
};

モーダルダイアログ側の全実装は下記の通り。

import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { Button, Form, Modal } from 'react-bootstrap';

export interface ModalSampleDialog2Ref {
  /**
   * ダイアログを表示する。
   * @param resultFunction 表示終了後に実行する関数。
   */
  showdDialog: (resultFunction?: ResultFunction) => void;
}

type ResultFunction = (value: string) => void;

/**
 * Modal sample dialog 2
 */
const ModalSampleDialog2 = forwardRef<ModalSampleDialog2Ref>((props, ref) => {

  const [showSelf, setShowSelf] = useState<boolean>(false);
  const resultFunctionRef = useRef<ResultFunction>(undefined);

  const [selectedFruit, setSelectedFruit] = useState<string>('');

  const handleSelect = (): void => {
    setShowSelf(false);
    if (resultFunctionRef.current) {
      resultFunctionRef.current(selectedFruit);
    }
  };

  useImperativeHandle(ref, () => ({

    showdDialog: (resultFunction?: ResultFunction): void => {
      setSelectedFruit('');
      resultFunctionRef.current = resultFunction;
      setShowSelf(true);
    }

  }));

  return (
    <Modal show={showSelf}>
      <Modal.Header>
        <Modal.Title>Modal Sample dialog 2</Modal.Title>
      </Modal.Header>
      <Modal.Body>
        <Form>
          <Form.Check
            label='Apple'
            name='fruit'
            type='radio'
            value='Apple'
            onChange={(event): void => setSelectedFruit(event.target.value)}
          />
          <Form.Check
            label='Banana'
            name='fruit'
            type='radio'
            value='Banana'
            onChange={(event): void => setSelectedFruit(event.target.value)}
          />
        </Form>
      </Modal.Body>
      <Modal.Footer>
        <Button onClick={handleSelect}>Select</Button>
      </Modal.Footer>
    </Modal>
  );

});

ModalSampleDialog2.displayName = ModalSampleDialog2.name;

export default ModalSampleDialog2;

呼び出し側の全実装は下記の通り。

import React, { useRef, useState } from 'react';
import { Button, Col, Container, Row } from 'react-bootstrap';

import ModalSampleDialog2, { ModalSampleDialog2Ref } from './ModalSampleDialog2';

/**
 * Modal sample 2
 */
export default function ModalSample2(): React.JSX.Element {

  const modalSampleDialog2Ref = useRef<ModalSampleDialog2Ref>(null);
  const [selectedFruit, setSelectedFruit] = useState<string>('');

  /**
   * ダイアログを表示する。
   */
  const handleShowDialog = (): void => {
    modalSampleDialog2Ref.current?.showdDialog((value) => {
      setSelectedFruit(value);
    });
  };

  return (
    <div>
      <h2>Modal Sample 2</h2>
      <Container fluid>
        <Row>
          <Col>
            <Button onClick={handleShowDialog}>Show dialog</Button>
            <label>{selectedFruit}</label>
          </Col>
        </Row>
      </Container>

      <ModalSampleDialog2 ref={modalSampleDialog2Ref} />
    </div>
  );

}

Promise 化

次は Promise を検討した。コードはコールバック関数とほぼ同じ実装をになるが、 showdDialog メソッドを Promise に変更するため、 Ref インターフェイスの showdDialog は、コールバック関数用の引数を削除して、戻り値を Promise に変更した。

export interface ModalSampleDialog3Ref {
  /**
   * ダイアログを表示する。
   * @returns 選択した値を返す。
   */
  showdDialog: () => Promise<string>;
}

useImperativeHandle の定義もインターフェイスと同じように変更する。 resultFunctionRef には resolve を保持しておき、コールバック関数の時と同じように Select ボタンクリック時に呼び出す。

useImperativeHandle(ref, () => ({

  showdDialog: async (): Promise<string> => {
    return await new Promise((resolve: ResultFunction) => {
      setSelectedFruit('');
      resultFunctionRef.current = resolve;
      setShowSelf(true);
    });
  }

}));

呼び出し側では戻り値を then で受け取って処理出来る。

const handleShowDialog = async (): Promise<void> => {
  await modalSampleDialog3Ref.current?.showdDialog()
    .then(value => {
      setSelectedFruit(value);
    });
};

モーダルダイアログ側の全実装は下記の通り。

import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { Button, Form, Modal } from 'react-bootstrap';

export interface ModalSampleDialog3Ref {
  /**
   * ダイアログを表示する。
   * @returns 選択した値を返す。
   */
  showdDialog: () => Promise<string>;
}

type ResultFunction = (value: string) => void;

/**
 * Modal sample dialog 3
 */
const ModalSampleDialog3 = forwardRef<ModalSampleDialog3Ref>((props, ref) => {

  const [showSelf, setShowSelf] = useState<boolean>(false);
  const resultFunctionRef = useRef<ResultFunction>(undefined);

  const [selectedFruit, setSelectedFruit] = useState<string>('');

  const handleSelect = (): void => {
    setShowSelf(false);
    if (resultFunctionRef.current) {
      resultFunctionRef.current(selectedFruit);
    }
  };

  useImperativeHandle(ref, () => ({

    showdDialog: async (): Promise<string> => {
      return await new Promise((resolve: ResultFunction) => {
        setSelectedFruit('');
        resultFunctionRef.current = resolve;
        setShowSelf(true);
      });
    }

  }));

  return (
    <Modal show={showSelf}>
      <Modal.Header>
        <Modal.Title>Modal Sample dialog 3</Modal.Title>
      </Modal.Header>
      <Modal.Body>
        <Form>
          <Form.Check
            label='Apple'
            name='fruit'
            type='radio'
            value='Apple'
            onChange={(event): void => setSelectedFruit(event.target.value)}
          />
          <Form.Check
            label='Banana'
            name='fruit'
            type='radio'
            value='Banana'
            onChange={(event): void => setSelectedFruit(event.target.value)}
          />
        </Form>
      </Modal.Body>
      <Modal.Footer>
        <Button onClick={handleSelect}>Select</Button>
      </Modal.Footer>
    </Modal>
  );

});

ModalSampleDialog3.displayName = ModalSampleDialog3.name;

export default ModalSampleDialog3;

呼び出し側の全実装は下記の通り。

import React, { useRef, useState } from 'react';
import { Button, Col, Container, Row } from 'react-bootstrap';

import ModalSampleDialog3, { ModalSampleDialog3Ref } from './ModalSampleDialog3';

/**
 * Modal sample 3
 */
export default function ModalSample3(): React.JSX.Element {

  const modalSampleDialog3Ref = useRef<ModalSampleDialog3Ref>(null);
  const [selectedFruit, setSelectedFruit] = useState<string>('');

  /**
   * ダイアログを表示する。
   */
  const handleShowDialog = async (): Promise<void> => {
    await modalSampleDialog3Ref.current?.showdDialog()
      .then(value => {
        setSelectedFruit(value);
      });
  };

  return (
    <div>
      <h2>Modal Sample 3</h2>
      <Container fluid>
        <Row>
          <Col>
            <Button onClick={handleShowDialog}>Show dialog</Button>
            <label>{selectedFruit}</label>
          </Col>
        </Row>
      </Container>

      <ModalSampleDialog3 ref={modalSampleDialog3Ref} />
    </div>
  );

}

終わりに

useImperativeHandle(と forwardRef)を使った、メソッド外部公開化の良い勉強になった。