[Material-UI] Select Componentで依存関係のある2つのコンボボックスを実装してみた

2021.07.31

こんにちは、CX事業本部 IoT事業部の若槻です。

ReactのUIライブラリMaterial-UIでは、コンボボックスやマルチセレクトメニュー(いわゆるドロップダウンリスト)を実装するためのSelect Componentが提供されています。

今回は、Material-UIのSelect Componentで依存関係のある2つのコンボボックスを実装してみました。

環境

  • React:17.0.1
  • Material-UI:4.12.3

デモ

アンケートと質問のコンボボックスです。アンケートのコンボボックスで選択を変更すると、選択中のアンケートに依存する質問が、質問コンボボックスから選択可能となります。

ソースコードは上記のCodeSandboxのサンドボックスをご確認ください。

コード概要

コンボボックス本体となる<ComboBox />の実装は再利用可能とするために別モジュールとして作成しています。Selectに加えて、FormControl、MenuItem、InputLabelなどのMaterial-UIのComponentも使用しています。

src/components/atoms/ComboBox.tsx

import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import MenuItem from "@material-ui/core/MenuItem";
import FormControl from "@material-ui/core/FormControl";
import Select from "@material-ui/core/Select";
import InputLabel from "@material-ui/core/InputLabel";

const useStyles = makeStyles((theme) => ({
  formControl: {
    margin: theme.spacing(2),
    marginBottom: theme.spacing(5),
  },
}));

export interface ComboBoxItem {
  id: string;
  value: string;
}

type Props = {
  inputLabel: string;
  items: ComboBoxItem[];
  defaultValue: string;
  value: string;
  onChange: (selected: string) => void;
};

const ComboBox: React.FC<Props> = (props) => {
  const { inputLabel, items, value, defaultValue, onChange } = props;
  const classes = useStyles();

  return (
    <FormControl className={classes.formControl}>
      <InputLabel>{inputLabel}</InputLabel>
      <Select
        defaultValue={defaultValue}
        value={value}
        onChange={(e) => {
          if (e.target.value !== undefined) {
            onChange(e.target.value as string);
          }
        }}
      >
        {items.map((item) => (
          <MenuItem value={item.id} key={item.id}>
            {item.value}
          </MenuItem>
        ))}
      </Select>
    </FormControl>
  );
};

export default ComboBox;

アンケートデータは、各アンケートの配下に複数の質問がある入れ子の依存関係となっています。useStateuseRefでコンボボックスでの選択状態を管理して<ComboBox />にプロパティとして渡すことより、アンケートと質問それぞれを2つのコンボボックスで依存性を持って選択できるようにしています。

src/App.tsx

import "./styles.css";
import React, { useState, useRef } from "react";

import ComboBox, { ComboBoxItem } from "./components/atoms/ComboBox";

interface EnqueteResult {
  enqueteId: string;
  enqueteTitle: string;
  questions: {
    questionId: string;
    questionText: string;
    answerCount: number;
  }[];
}

const EnqueteResultList: EnqueteResult[] = [
  {
    enqueteId: "1",
    enqueteTitle: "会社説明会(7月)",
    questions: [
      {
        questionId: "1",
        questionText: "得意なプログラム言語は?",
        answerCount: 15,
      },
      {
        questionId: "2",
        questionText: "興味のある職種は?",
        answerCount: 12,
      },
    ],
  },
  {
    enqueteId: "2",
    enqueteTitle: "会社説明会(8月)",
    questions: [
      {
        questionId: "1",
        questionText: "興味のある職種は?",
        answerCount: 23,
      },
      {
        questionId: "2",
        questionText: "弊社の求人をどこで知りましたか?",
        answerCount: 24,
      },
    ],
  },
  {
    enqueteId: "3",
    enqueteTitle: "DevelopersIOイベント",
    questions: [
      {
        questionId: "1",
        questionText: "今回のイベントをどこで知りましたか?",
        answerCount: 40,
      },
      {
        questionId: "2",
        questionText: "興味のある職種は?",
        answerCount: 37,
      },
      {
        questionId: "3",
        questionText: "得意なプログラム言語は?",
        answerCount: 29,
      },
    ],
  },
];

const App = () => {
  //ComboBoxのアイテムとするアンケート一覧をStateで管理
  const [enqueteOptions] = useState<ComboBoxItem[]>(
    EnqueteResultList.map((d) => {
      return {
        id: d.enqueteId,
        value: d.enqueteTitle,
      };
    })
  );
  //アンケートComboBoxで選択中のアンケートIDをStateで管理
  const [selectedEnqueteId, setSelectedEnqueteId] = useState<string>(
    EnqueteResultList[0].enqueteId
  );
  //選択中のアンケートの質問一覧をRefで管理
  const questionOptionsRef = useRef(
    EnqueteResultList.filter(
      (d) => d.enqueteId === selectedEnqueteId
    )[0].questions.map((d) => {
      return {
        id: d.questionId,
        value: d.questionText,
      };
    })
  );
  //質問ComboBoxで選択中の質問IDをStateで管理
  const [selectedQuestionId, setSelectedQuestionId] = useState(
    EnqueteResultList[0].questions[0].questionId
  );

  const onEnqueteComboBoxChangeHandler = (enqueteId: string) => {
    //選択したアンケートIDをStateに指定
    setSelectedEnqueteId(enqueteId);

    //選択したアンケートの質問一覧
    const selectedEnqueteQuestions = EnqueteResultList.filter(
      (d) => d.enqueteId === enqueteId
    )[0].questions;

    //選択したアンケートの先頭の質問をStateに指定
    setSelectedQuestionId(selectedEnqueteQuestions[0].questionId);

    //選択したアンケートの質問をRefに指定
    questionOptionsRef.current = selectedEnqueteQuestions.map((d) => {
      return {
        id: d.questionId,
        value: d.questionText,
      };
    });
  };

  return (
    <>
      <ComboBox
        inputLabel="アンケート"
        items={enqueteOptions}
        value={selectedEnqueteId}
        defaultValue={enqueteOptions[0].id}
        onChange={(selected) => onEnqueteComboBoxChangeHandler(selected)}
      />
      <ComboBox
        inputLabel="質問"
        items={questionOptionsRef.current}
        value={selectedQuestionId}
        defaultValue={"1"}
        onChange={(selected) => setSelectedQuestionId(selected)}
      />
      <div>
        この質問へは{" "}
        {
          EnqueteResultList.filter(
            (d) => d.enqueteId === selectedEnqueteId
          )[0].questions.filter((d) => d.questionId === selectedQuestionId)[0]
            .answerCount
        }{" "}
        の回答がありました。
      </div>
    </>
  );
};

export default App;

ハマった箇所

アンケートの選択変更時に質問の選択がリセットされない

アンケートのコンボボックスの選択変更時に、質問のコンボボックスの選択が先頭に戻らない事象でハマりました。例えば「アンケート1」と「アンケート1の質問2」が選択された状態で、「アンケート2」を選択すると、「アンケート2の質問1」が選択されて欲しいのに「アンケート1の質問2」が選択されたままになるという事象です。

当初はSelect APIのバグかと思いましたが、原因はSelectのvalueプロパティを指定していないためでした。選択を直接手動で変更する場合はvalueの指定はいりませんが、今回のようにあるコンボボックスのオプションが別のコンボボックスの選択に依存する場合はvalueで明示的に指定する必要がありました。

src/components/atoms/ComboBox.tsx

      <Select
        defaultValue={defaultValue}
        value={value}
        onChange={(e) => {
          if (e.target.value !== undefined) {
            onChange(e.target.value as string);
          }
        }}
      >
        {items.map((item) => (
          <MenuItem value={item.id} key={item.id}>
            {item.value}
          </MenuItem>
        ))}
      </Select>

おわりに

Material-UIのSelect Componentで依存関係のある2つのコンボボックスを実装してみました。

Material UIでフィルターを実装したことは何度かありましたが、今回のパターンは経験が無かったので少しハマりました。

以上