【React】Material UIのAutocomplateで初期値(初期選択)が反映されなかった件

Material UIのAutocomplateでサジェスト機能を持ったセレクトボックスを実装した際に、初期値(初期選択)の設定がうまく反映されなかったので、自分なりの解決方法を備忘録として残します。

Material UIのAutocomplate実装例

以下のコードがDemoにもあるような標準的な使い方です。
青線部はセレクトボックスの候補リスト、オレンジ線部は初期値です。

import { useState, useEffect } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Box from '@mui/material/Box';

const options = ['1行目', '2行目', '3行目'];
type Props = {
  show: boolean;
}
const TestPage = (props: Props) => {
  const [value, setValue] = useState<string | null>('1行目');

  return (
    <>
    { props.show ? (
    <div className="overlay-dark">
      <Box component='div' sx={{ p: 5, backgroundColor: '#fff', height: '90%', width: '60vw', minWidth: '400px', border: "0.5px solid #000", boxShadow: "1px 1px 2px rgba(0, 0, 0, 0.5)" }}>
        <div>{`value: ${value}`}</div>
        <br />
        <Autocomplete 
          options={options}
          value={value}
          renderInput={(params) => (
            <TextField 
              {...params}
              label="TestController"
            />
          )}
        />
      </Box>
    </div>
    ) : (
      <></>
    )}
    </>
  );
}
export default TestPage;

実行結果としては以下の感じです。
valueとして、初期値の変数の中身を表示しています。
セレクトボックスの初期選択も、初期値の設定通りとなっています。
セレクトボックスの候補もしっかり設定されていますね。

今回やりたかったこと

上記実装例では、セレクトボックスの候補がstringの配列なので、初期値もstringを直に指定する形にしましたが、実際のシーンではコード値と名称のオブジェクト型で設定することが多いと思います。
また、セレクトボックスの候補も定数として用意しましたが、実際のシーンではDBから設定することがあると思います。(DBから取得=APIで取得=非同期処理)

失敗パターン

この2点を踏まえて、サンプルを以下のように記述してみました。

青線部はセレクトボックスの候補設定です。
DB取得(=API取得)を想定して、useEffectから非同期処理をコールして、その中で設定しています。

オレンジ線部は初期値の設定です。
値自体はコードで設定し、そのコードから候補リストを照合して、該当するオブジェクトで初期選択しています。
(Autocomplateの仕様上、コードでダイレクトの設定はできず、候補の型に合わせた設定が必要なようです)

緑線部は、蛇足ですがAutocomplateで選択(入力)した際にコードの変数に値を格納するロジックです。

import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Box from '@mui/material/Box';
import { useState, useEffect } from 'react';

const initOptions = [{value: 1, label: '1行目'}, {value: 2, label: '2行目'}, {value: 3, label: '3行目'}];
type Option = {
  value: number;
  label: string;
}
type Props = {
  show: boolean;
}
const TestPage = (props: Props) => {
  const [value, setValue] = useState<number | null>(2);
  const [options, setOptions] = useState<Option[]>([]);

  useEffect(() => {
    if(props.show){
      handleGetOptions();
    }
  }, [props.show]);

  const handleGetOptions = async () => {
    setOptions(initOptions);
  }

  const handleChange = (selectedOption: Option | null) => {
    setValue(selectedOption?.value || null);
  }

  const setSelectOption = () => {
    const selectOption = options.find((v) => v.value===value);
    return selectOption;
  }

  return (
    <>
    { props.show ? (
    <div className="overlay-dark">
      <Box component='div' sx={{ p: 5, backgroundColor: '#fff', height: '90%', width: '60vw', minWidth: '400px', border: "0.5px solid #000", boxShadow: "1px 1px 2px rgba(0, 0, 0, 0.5)" }}>
        <div>{`value: ${value}`}</div>
        <div>{`selectOption: ${setSelectOption()?.value || 'null'} label: ${setSelectOption()?.label || 'null'}`}</div>
        <br />
        <Autocomplete 
          options={options}
          value={setSelectOption()}
          onChange={(_event,newTerm) => {
            handleChange(newTerm);
          }}
          renderInput={(params) => (
            <TextField 
              {...params}
              label="TestController"
            />
          )}
        />
      </Box>
    </div>
    ) : (
      <></>
    )}
    </>
  );
}
export default TestPage;

さて、実行してみましょう。

まず、初期選択がされていません。
valueは初期設定としたコード値、selectOptionは初期値から候補照合したオブジェクトの値です。
候補から初期選択用のオブジェクトは取得できていますが、セレクトボックスは未選択になっています。
セレクトボックスには候補はしっかり設定されています。

検証パターン①

原因を切り分けるために、ちょっとだけコードを変えてみます。
失敗パターンからの変更です。

オレンジ線部のところを、コード値からオブジェクト照合する形ではなく、ダイレクトに候補リスト2行目を設定するイメージに変えました。(この変更で不都合が出る部分は適宜変更しています)

import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Box from '@mui/material/Box';
import { useState, useEffect } from 'react';

const initOptions = [{value: 1, label: '1行目'}, {value: 2, label: '2行目'}, {value: 3, label: '3行目'}];
type Option = {
  value: number;
  label: string;
}
type Props = {
  show: boolean;
}
const TestPage = (props: Props) => {
  const [selectOption, setSelectOption] = useState<Option | null>(initOptions[1]);
  const [options, setOptions] = useState<Option[]>([]);

  useEffect(() => {
    if(props.show){
      handleGetOptions();
    }
  }, [props.show]);

  const handleGetOptions = async () => {
    setOptions(initOptions);
  }

  return (
    <>
    { props.show ? (
    <div className="overlay-dark">
      <Box component='div' sx={{ p: 5, backgroundColor: '#fff', height: '90%', width: '60vw', minWidth: '400px', border: "0.5px solid #000", boxShadow: "1px 1px 2px rgba(0, 0, 0, 0.5)" }}>
        <div>{`selectOption: ${selectOption?.value || 'null'} label: ${selectOption?.label || 'null'}`}</div>
        <br />
        <Autocomplete 
          options={options}
          value={selectOption}
          renderInput={(params) => (
            <TextField 
              {...params}
              label="TestController"
            />
          )}
        />
      </Box>
    </div>
    ) : (
      <></>
    )}
    </>
  );
}
export default TestPage;

実行結果です。

ちゃんと2行目が初期選択されました。

検証パターン②

失敗パターンのプログラムに戻して、別な変更を試します。

ちょっとわかりにくいですが、青線部は非同期処理でセレクトボックスの候補設定するのをやめて、定数から設定するようにしました。

import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Box from '@mui/material/Box';
import { useState } from 'react';

const options = [{value: 1, label: '1行目'}, {value: 2, label: '2行目'}, {value: 3, label: '3行目'}];
type Option = {
  value: number;
  label: string;
}
type Props = {
  show: boolean;
}
const TestPage = (props: Props) => {
  const [value, setValue] = useState<number | null>(2);

  const handleChange = (selectedOption: Option | null) => {
    setValue(selectedOption?.value || null);
  }

  const setSelectOption = () => {
    const selectOption = options.find((v) => v.value===value);
    return selectOption;
  }

  return (
    <>
    { props.show ? (
    <div className="overlay-dark">
      <Box component='div' sx={{ p: 5, backgroundColor: '#fff', height: '90%', width: '60vw', minWidth: '400px', border: "0.5px solid #000", boxShadow: "1px 1px 2px rgba(0, 0, 0, 0.5)" }}>
        <div>{`value: ${value}`}</div>
        <div>{`selectOption: ${setSelectOption()?.value || 'null'} label: ${setSelectOption()?.label || 'null'}`}</div>
        <br />
        <Autocomplete 
          options={options}
          value={setSelectOption()}
          onChange={(_event,newTerm) => {
            handleChange(newTerm);
          }}
          renderInput={(params) => (
            <TextField 
              {...params}
              label="TestController"
            />
          )}
        />
      </Box>
    </div>
    ) : (
      <></>
    )}
    </>
  );
}
export default TestPage;

実行結果です。

ちゃんと2行目が初期選択されました。

考察

失敗パターン、検証パターン①②を総合的に考察すると・・・

セレクトボックスの候補を非同期処理で設定 + コードからオブジェクト照合して初期値設定 の組み合わせが原因のようです。

ここからは推測になりますが、useEffectやasyncでの非同期、更にuseState(setState)はタイミングが保証されるものではないので、セレクトボックスの候補(オブジェクト)が生成される前に、コードからオブジェクト照合しているのではないかと思われます。

解決策

どうにかしてセレクトボックスの候補(オブジェクト)生成後に、コードからのオブジェクト照合が動くようにコントロールできないかを考えてみました。
コードに対してsetStateを走らせることも試しましたが、セレクトボックス候補オブジェクト生成のトリガに乗るのが精一杯だったので順序性がコントロールできません。

最終的に自分なりに工夫した方法が以下のコードです。
失敗パターンのプログラムから工夫しています。

ポイントは青線部です。
セレクトボックスの候補オブジェクトが生成されてから、Autocomplate自体を生成するように条件付けしました。
そうすることで、コードからオブジェクト照合が、候補オブジェクト確立後に発生するので順序性をコントロールできたと思います。

import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Box from '@mui/material/Box';
import { useState, useEffect } from 'react';

const initOptions = [{value: 1, label: '1行目'}, {value: 2, label: '2行目'}, {value: 3, label: '3行目'}];
type Option = {
  value: number;
  label: string;
}
type Props = {
  show: boolean;
}
const TestPage = (props: Props) => {
  const [value, setValue] = useState<number | null>(2);
  const [options, setOptions] = useState<Option[]>([]);

  useEffect(() => {
    if(props.show){
      handleGetOptions();
    }
  }, [props.show]);

  const handleGetOptions = async () => {
    setOptions(initOptions);
  }

  const handleChange = (selectedOption: Option | null) => {
    setValue(selectedOption?.value || null);
  }

  const setSelectOption = () => {
    const selectOption = options.find((v) => v.value===value);
    return selectOption;
  }

  return (
    <>
    { props.show ? (
    <div className="overlay-dark">
      <Box component='div' sx={{ p: 5, backgroundColor: '#fff', height: '90%', width: '60vw', minWidth: '400px', border: "0.5px solid #000", boxShadow: "1px 1px 2px rgba(0, 0, 0, 0.5)" }}>
        <div>{`value: ${value}`}</div>
        <div>{`selectOption: ${setSelectOption()?.value || 'null'} label: ${setSelectOption()?.label || 'null'}`}</div>
        <br />
        { options.length &&
        <Autocomplete 
          options={options}
          value={setSelectOption()}
          onChange={(_event,newTerm) => {
            handleChange(newTerm);
          }}
          renderInput={(params) => (
            <TextField 
              {...params}
              label="TestController"
            />
          )}
        />
        }
      </Box>
    </div>
    ) : (
      <></>
    )}
    </>
  );
}
export default TestPage;

そして実行結果です。
きちんと初期選択されました。

(追記)

上記解決策に落とし穴がありました。
上記事例では、セレクトボックスのリスト候補のみDB取得想定(非同期)だったので、リスト候補オブジェクト確立のための条件だけでOKでしたが、初期値の方もDB取得(非同期)だった場合は、こちらで空振りすることも考慮しなければなりません。

最初は安易に「{ (options.length && value」の条件で対応しようとしましたが、初期値がnull値の場合は漏れなくAutocomplate自体が表示されないままになってしまいます。初期値にnullが入ること仕様上ある場合はダメです。

上記解決策のもう一つの落とし穴として、リスト候補がDB取得の結果0件だった場合も、Autocomplate自体が表示されないままとなってしまいます。「options.length」なので件数が1件以上ないと条件を満たさないのです。

これらの解決策としては、苦し紛れにはなりますが、DB取得処理(非同期処理)の完了を状態判断できるstateを別に設けて、それを条件にするような工夫が必要です。

最後に

今回、この事象にハマり、ネット上の記事を探しましたが答えに辿り着けず途方に暮れました。
いろいろ予測を立て、試した結果、自分なりの解決策に辿り着いた次第です。
Reactをさわりはじめて1年くらい経ち、少しだけ理解が深まっていたことを実感しました。

おしまい

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です