React

MUI Autocompleteの実装と落とし穴|isOptionEqualToValueとv9のslotProps移行

MUI Autocompleteの実装と落とし穴|isOptionEqualToValueとv9のslotProps移行

MUI(Material UI)のAutocompleteは、入力に応じて候補を絞り込むReactのコンポーネントです。文字列の配列を渡すだけなら数行で動きますが、実務で扱うオブジェクトの配列を渡した途端に「選んだはずの項目が選択済みにならない」「初期値が入力欄に出ない」といった詰まり方をします。しかも原因になりやすい警告は、あるバージョンを境にコンソールから消えました。この記事では @mui/material 9.3.0 と React 19.2.8 で実際にコンポーネントを描画し、コンソール出力とDOMを確認したうえで、症状ごとの原因と対処をコード付きで整理します。なお、HTMLのinput要素が持つautocomplete属性(ブラウザのオートフィル)は同名の別機能です。その切り分けは後半で扱います。

まとめ

  • optionsにオブジェクトを渡すならisOptionEqualToValueは実質必須です。既定の比較は厳密等価(===)なので、APIから取り直した同じ内容のオブジェクトは別物と判定されます。
  • その不一致を知らせる警告はv6で削除されました。v6以降は入力欄の表示が正しいまま選択状態だけが欠落するので、コンソールを見ても気づけません。
  • 初期値はnullで与えます。undefinedで初期化すると非制御コンポーネントとして確定し、後から値を渡しても反映されません。
  • v9ではrenderInputに渡るparamsの構造が変わりました。params.InputPropsは廃止されparams.slotProps.inputになっています。ネット上の非同期サンプルはほぼv5世代の書き方なので、そのままでは型エラーになります。
  • Material UIにv8は存在しません。v7の次が9.0.0です。

以下、症状別に原因と実装を見ていきます。

基本構成|optionsとrenderInputの最小実装

文字列とlabelキーで動く既定の挙動

Autocompleteが最低限必要とするのはoptionsとrenderInputの2つです。renderInputは入力欄そのものを描く関数で、受け取ったparamsをTextFieldへ展開します。optionsの要素が文字列、またはlabelキーを持つオブジェクトであれば、ラベルの取り出しは追加設定なしで動きます。

import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';

const frameworks = [{ label: 'React' }, { label: 'Vue' }, { label: 'Svelte' }];

export function FrameworkSelect() {
  return (
    <Autocomplete
      options={frameworks}
      sx={{ width: 320 }}
      renderInput={(params) => <TextField {...params} label="フレームワーク" />}
    />
  );
}

この形が成立するのは、既定のgetOptionLabelが「文字列ならそのまま、オブジェクトならlabelプロパティ」を読む実装になっているためです。データ側のキー名をlabelに寄せられるなら、この最小構成のまま進めて構いません。

オブジェクト配列でのgetOptionLabel指定

実際のAPIレスポンスはlabelではなくnameやtitleを返すことがほとんどです。キーが合わないまま渡すと、入力欄には[object Object]が表示され、開発ビルドでは次のエラーが出ます。

MUI: The `getOptionLabel` method of Autocomplete returned object ([object Object])
instead of a string for {"id":1,"name":"React"}.

このエラーはgetOptionLabelに「オブジェクトから表示用の文字列を返す関数」を渡せば解消します。返り値は必ず文字列にしてください。数値のIDをそのまま返すと同じエラーになります。

選択済みの項目が反映されない原因|isOptionEqualToValueの実測

Autocompleteでもっとも多い詰まりが、値をセットしているのに候補リスト上で選択済みとして扱われない症状です。原因は値と選択肢の比較方法にあります。既定のisOptionEqualToValueは(option, value) => option === valueという厳密等価です。optionsが文字列の配列なら中身が同じ文字列は一致しますが、オブジェクトの場合は参照が同一でなければ一致しません。サーバーから取得したvalueと、別途取得したoptionsは、中身が同じでも異なるオブジェクトです。

v6で削除された警告メッセージ

この不一致は、かつてコンソールが教えてくれていました。v5系には次の警告が実装されていました。

MUI: The value provided to Autocomplete is invalid.
None of the options match with `{"id":1,"name":"React"}`.
You can use the `isOptionEqualToValue` prop to customize the equality test.

ところがこの文字列は、v6.0.0以降のパッケージから削除されています。npmで配布されている各版のtarballを取得して該当文字列を検索した結果が次のとおりです。

バージョン 不一致の警告
5.18.0 あり
6.0.0 なし
6.5.0 なし
7.3.11 なし
9.3.0 なし

つまり「isOptionEqualToValue」という語で検索してたどり着く警告文は、v5世代の記憶です。v6以降を使っている場合、同じ不具合が起きていてもコンソールは沈黙します。症状から原因を引く必要があるため、次に実際の壊れ方を示します。

aria-selectedで確認した実測結果

9.3.0のAutocompleteを候補リストを開いた状態で描画し、li[role="option"]のaria-selectedを採取しました。valueにはoptionsと同じ内容で参照だけが異なるオブジェクトを渡しています。

条件 入力欄の表示 該当optionのaria-selected コンソール出力
isOptionEqualToValueなし・別参照 React false なし
isOptionEqualToValueあり・別参照 React true なし
options内の同一参照 React true なし

注目すべきは、3条件とも入力欄には正しく「React」と表示される点です。画面を一見しただけでは正常に見えるため、バグとして報告されるのはたいてい「リストを開いても選択中の項目にチェックが付かない」「複数選択でチップは出るのに候補側の状態が合わない」という形になります。実際、multipleでも同じ結果で、比較関数を渡さない限り選択済みの選択肢は一つも印が付きませんでした。スクリーンリーダーはaria-selectedを読むため、これは見た目の問題では済まずアクセシビリティの欠陥になります。

idを比較するisOptionEqualToValueの実装

対処は、業務上の同一性を表すキーで比較する関数を渡すことです。多くの場合はIDの一致で足ります。あわせて値の状態もvalueとonChangeで持っておくと、後述の初期値の問題も同時に避けられます。

import * as React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';

export type Framework = { id: number; name: string };

export const list: Framework[] = [
  { id: 1, name: 'React' },
  { id: 2, name: 'Vue' },
  { id: 3, name: 'Svelte' },
];

export function FrameworkSelectById() {
  const [value, setValue] = React.useState<Framework | null>(null);

  return (
    <Autocomplete
      options={list}
      value={value}
      onChange={(_event, newValue) => setValue(newValue)}
      getOptionLabel={(option) => option.name}
      isOptionEqualToValue={(option, selected) => option.id === selected.id}
      renderInput={(params) => <TextField {...params} label="フレームワーク" />}
    />
  );
}

比較関数を書くときは、必ず1件だけに一致する条件にしてください。multipleを使っている場合、1つの選択肢が複数の値に一致すると開発ビルドでdoes not handle the arguments correctlyを含むエラーが出ます。名前など重複しうる項目での比較は避けるべきです。なお以降の例では、このFramework型とlistを再利用します。

初期値の設定|nullとundefinedで変わる制御の扱い

undefinedで初期化したときの挙動

フォームの初期値をAPIから取得する画面では、取得完了までvalueがundefinedになる書き方をしがちです。MUIは初回レンダリング時点でvalueがundefinedかどうかを見て、制御コンポーネントか非制御かを決めます。後から値を渡すと次のエラーになります。

MUI: A component is changing the uncontrolled value state of Autocomplete to be controlled.
Elements should not switch from uncontrolled to controlled (or vice versa).
Decide between using a controlled or uncontrolled Autocomplete element for the lifetime of the component.
The nature of the state is determined during the first render. It's considered controlled if the value is not `undefined`.

厄介なのは、警告が出るだけでは終わらない点です。実際にundefinedから値ありへ切り替える手順を再現したところ、エラーが出たうえで入力欄の値は空文字のままでした。渡した値は画面に反映されません。

nullで初期化する書き方

単一選択ではnull、multipleでは空配列を初期値にします。これらは「値が未選択である」ことを明示するため、初回から制御コンポーネントとして確定します。同じ手順をvalue={null}始まりで再現すると、警告は出ず、後から渡した値が入力欄に反映されました。前掲のコードでReact.useState<Framework | null>(null)としているのはこのためです。データ取得中に一時的にundefinedを経由させないよう、状態の初期値そのものをnullにしておくのが確実です。

複数選択と自由入力|multipleとfreeSoloの設定

multipleでのrenderOptionとkeyの分離

multipleを付けると値は配列になり、選択済みの項目はチップとして表示されます。候補の行を自前で描く場合はrenderOptionを使いますが、v6.0.0以降は第1引数のpropsにkeyが含まれています(v5には含まれません)。propsをそのまま展開するとkeyをスプレッド経由で渡すことになり、React 19はA props object containing a "key" prop is being spread into JSXを出します。分割代入で取り出して個別に指定してください。

import * as React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import { type Framework, list } from './frameworks';

export function TechSelect() {
  const [values, setValues] = React.useState<Framework[]>([]);

  return (
    <Autocomplete
      multiple
      options={list}
      value={values}
      onChange={(_event, newValues) => setValues(newValues)}
      getOptionLabel={(option) => option.name}
      isOptionEqualToValue={(option, selected) => option.id === selected.id}
      renderOption={(props, option) => {
        const { key, ...optionProps } = props;
        return (
          <li key={key} {...optionProps}>
            {option.name}
          </li>
        );
      }}
      renderInput={(params) => <TextField {...params} label="使用技術" />}
    />
  );
}

9.3.0で実際にpropsのキーを採取すると、key・role・aria-selected・data-option-indexなどが含まれていました。これらは候補のハイライトとaria-activedescendantの解決に使われる属性なので、liへ渡さずに独自の要素だけを描くとキーボード操作の前提が崩れます。propsは必ず展開してください。

freeSoloでの入力値の受け取り

freeSoloは候補に無い文字列の入力を許可する設定で、検索窓のように使う場面に向きます。ただし選択された値と入力中の文字列は別の状態です。前者はvalueとonChange、後者はinputValueとonInputChangeで扱い、MUIの公式ドキュメントも両者を独立して制御するよう明示しています。検索キーワードを取りたいだけなら、入力値の側だけを持てば十分です。

import * as React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import { list } from './frameworks';

export function KeywordInput() {
  const [input, setInput] = React.useState('');

  return (
    <Autocomplete
      freeSolo
      options={list.map((item) => item.name)}
      inputValue={input}
      onInputChange={(_event, newInput) => setInput(newInput)}
      renderInput={(params) => <TextField {...params} label="キーワード" />}
    />
  );
}

freeSoloではonChangeの値が選択肢の型ではなく文字列で来ることがあります。TypeScriptで扱うなら、値を使う側で型を絞り込むか、上の例のように入力値だけを扱う設計にすると破綻しません。

候補の絞り込み|createFilterOptionsと大量データ対策

一致条件とstringifyの指定

既定の絞り込みは、ラベルに対する部分一致です。前方一致に変えたい、検索対象をラベル以外の項目にも広げたいといった要件はcreateFilterOptionsで組み立てます。公式ドキュメントが挙げる設定項目はignoreAccents、ignoreCase、limit、matchFrom、stringify、trimです。

import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import { type Framework, list } from './frameworks';

const filterOptions = createFilterOptions<Framework>({
  matchFrom: 'start',
  ignoreCase: true,
  limit: 20,
  stringify: (option) => option.name,
});

export function FilteredSelect() {
  return (
    <Autocomplete
      options={list}
      filterOptions={filterOptions}
      getOptionLabel={(option) => option.name}
      isOptionEqualToValue={(option, selected) => option.id === selected.id}
      renderInput={(params) => <TextField {...params} label="フレームワーク" />}
    />
  );
}

stringifyは検索対象の文字列を組み立てる関数です。型番と製品名の両方でヒットさせたい場合など、複数の項目を連結して返すと意図どおりに絞り込めます。生成した関数はモジュールスコープに置いて使い回してください。絞り込み自体は候補リストを開いている間つねに実行されるため、関数まで毎レンダー作り直す必要はありません。

表示件数の制限と仮想化の判断

候補が増えても、リストを閉じている間の描画コストはほぼ変わりません。Autocompleteは候補リストを開いたときに初めて選択肢をDOMへ描くためです。負荷がかかるのは開いた瞬間で、既定では絞り込み後の候補が全件そのままli要素になります。jsdom上で件数を変えて計測すると、リストを開く描画は100件で約62ミリ秒、1,000件で約140ミリ秒、5,000件で約623ミリ秒、10,000件で約1,114ミリ秒と件数にほぼ比例して伸び、描画されたli要素の数は常に候補数と一致しました。絶対値は実行環境で変わりますが、既定では仮想化が働かず全件がDOMに載る構造は同じです。

軽い対処は前掲のlimitで、条件に合う候補が多いときに表示件数を打ち切ります。入力を促す用途ならこれで十分です。全件をスクロールさせる必要がある画面では描画そのものを間引く必要があり、公式ドキュメントは10,000件規模の例をreact-windowによる仮想化で示しています。仮想化はrenderOptionやリスト要素の差し替えを伴うので、まずlimitで足りるかを確かめてから導入する順序が無駄になりません。

非同期取得とv9のslotProps移行

renderInputに渡るparamsの構造変更

候補をAPIから取得する実装では、読み込み中にスピナーを出すためにrenderInputのparamsへ手を入れます。ここがv9で変わった箇所です。各版の型定義を比較すると次のようになっています。

バージョン 入力要素 ラベル input属性
5.18.0 InputProps InputLabelProps inputProps
7.3.11 InputProps InputLabelProps inputProps
9.3.0 slotProps.input slotProps.inputLabel slotProps.htmlInput

v9ではInputPropsが型から消え、slotPropsの下にまとめられました。検索で見つかる非同期サンプルの多くはv5からv7の世代なので、9系にそのまま貼るとTypeScriptがProperty 'InputProps' does not existを返します。移行は機械的で、参照先をparams.slotProps.inputに置き換えるだけです。

loading表示を組み込んだ実装

次が9系での書き方です。slotPropsを丸ごと上書きするとラベルやinput属性の設定が消えるため、既存のslotPropsを展開したうえでinputだけを差し替えます。

import * as React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import CircularProgress from '@mui/material/CircularProgress';
import { type Framework } from './frameworks';

export function AsyncSelect() {
  const [open, setOpen] = React.useState(false);
  const [options, setOptions] = React.useState<Framework[]>([]);
  const loading = open && options.length === 0;

  React.useEffect(() => {
    if (!loading) {
      return undefined;
    }
    const controller = new AbortController();

    fetch('/api/frameworks', { signal: controller.signal })
      .then((res) => res.json())
      .then((json: Framework[]) => setOptions(json))
      .catch((error: unknown) => {
        if (!(error instanceof DOMException && error.name === 'AbortError')) {
          console.error(error);
        }
      });

    return () => controller.abort();
  }, [loading]);

  return (
    <Autocomplete
      open={open}
      onOpen={() => setOpen(true)}
      onClose={() => setOpen(false)}
      options={options}
      loading={loading}
      getOptionLabel={(option) => option.name}
      isOptionEqualToValue={(option, selected) => option.id === selected.id}
      renderInput={(params) => (
        <TextField
          {...params}
          label="フレームワーク"
          slotProps={{
            ...params.slotProps,
            input: {
              ...params.slotProps.input,
              endAdornment: (
                <React.Fragment>
                  {loading ? <CircularProgress color="inherit" size={20} /> : null}
                  {params.slotProps.input.endAdornment}
                </React.Fragment>
              ),
            },
          }}
        />
      )}
    />
  );
}

取得処理にAbortControllerを添えているのは、候補リストを開閉するたびにリクエストが積み上がるのを防ぐためです。中断時の例外は通常のエラーと区別して握りつぶします。なおloadingを渡すと候補が空のときに読み込み中の表示へ切り替わるので、空配列を「該当なし」と誤って見せてしまう事故も防げます。

入力ごとの再取得を抑えるdebounce

入力語をサーバーへ送って候補を引く検索窓では、1文字ごとにリクエストが飛びます。一定時間入力が止まってから投げるようタイマーを挟み、クリーンアップでタイマーと通信の両方を取り消します。サーバー側で絞り込む以上、クライアント側の絞り込みは無効化しておかないと二重に候補が削られます。

import * as React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import { type Framework } from './frameworks';

export function DebouncedSearch() {
  const [input, setInput] = React.useState('');
  const [options, setOptions] = React.useState<Framework[]>([]);

  React.useEffect(() => {
    if (input === '') {
      setOptions([]);
      return undefined;
    }
    const controller = new AbortController();
    const timer = setTimeout(() => {
      fetch(`/api/frameworks?q=${encodeURIComponent(input)}`, { signal: controller.signal })
        .then((res) => res.json())
        .then((json: Framework[]) => setOptions(json))
        .catch(() => undefined);
    }, 300);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [input]);

  return (
    <Autocomplete
      options={options}
      filterOptions={(option) => option}
      inputValue={input}
      onInputChange={(_event, newInput) => setInput(newInput)}
      getOptionLabel={(option) => option.name}
      isOptionEqualToValue={(option, selected) => option.id === selected.id}
      renderInput={(params) => <TextField {...params} label="キーワード" />}
    />
  );
}

filterOptions={(option) => option}が、受け取った候補をそのまま返してクライアント側の絞り込みを止める指定です。これを忘れると、サーバーが返した候補にさらに入力文字列での部分一致が掛かり、表記ゆれや日本語の読み違いで候補が消える不具合になります。

ブラウザのオートフィルとautocomplete属性の切り分け

MUIのAutocompleteとautocomplete属性の役割分担

MUIのAutocompleteと、HTMLのinput要素が持つautocomplete属性は別物です。前者は候補リストをアプリ側で描くReactコンポーネント、後者はブラウザに保存された氏名や住所を補完させるための指定で、ワンタイムコード欄で使うautocomplete="one-time-code"などが該当します。

フォーム全体で入力を速くしたいだけなら、MUIのAutocompleteを持ち出す必要はありません。ブラウザ標準の補完に任せたほうが実装も保守も軽く済みます。コンポーネントを選ぶ前に、候補データを自分で持つ必要があるのかを確認してください。MUIのデザインごと持ち込みたくない場合は、Base UI(React)とは?特徴・Radix UI/MUIとの違い・使い方をわかりやすく解説で扱うようなヘッドレスの実装も選択肢になります。

補完候補が二重に出るときの抑制

MUIのAutocompleteを住所やメールアドレスの入力に使うと、ブラウザの補完候補がMUIの候補リストに重なって表示されることがあります。この場合はMUI側ではなく、input要素のautocomplete属性をブラウザ補完が働かない値に設定して抑制します。v9ではhtmlInputスロット経由で指定します。

renderInput={(params) => (
  <TextField
    {...params}
    label="お住まいの都道府県"
    slotProps={{
      ...params.slotProps,
      htmlInput: {
        ...params.slotProps.htmlInput,
        autoComplete: 'new-password',
      },
    }}
  />
)}

v5からv7では同じ指定をinputProps経由で行います。なおoffは主要ブラウザが無視することがあるため、保存済みの値を出したくない入力欄では上のように既知でない値を使うのが定石です。入力値の妥当性そのものを担保する話は別レイヤーなので、バリデーションとは?入力検証の目的・種類・実装パターンと設計を実装者視点で解説とあわせて設計してください。

よくある質問

valueがnullのとき、isOptionEqualToValueは呼ばれますか?

呼ばれません。比較は値が存在する場合にだけ行われるため、未選択状態でnullを渡していて比較関数が実行されないのは正常です。未選択時にエラーが出る場合は、比較関数の中でvalueのプロパティへ無条件にアクセスしている実装を疑ってください。

コンソールに出るMUIのエラーは、どれから直すべきですか?

getOptionLabelが文字列を返していない旨のエラーが最優先です。表示が壊れているためです。次が非制御から制御への切り替えで、これは値が反映されない直接の原因になります。一方、選択状態が合わない不具合はv6以降エラーを出しません。コンソールが静かでも症状が出ているならisOptionEqualToValueを疑ってください。

複数選択で、選んだ項目にチェックボックスを出せますか?

出せます。renderOption={(props, option, { selected }) => ...}のように第3引数から選択状態を分割代入で取り出し、Checkboxのcheckedに渡します。keyをpropsから分離する処理は本文の複数選択の例と同じです。選択のたびにリストが閉じないようdisableCloseOnSelectも添えます。なお比較関数を渡していないとselectedが常にfalseになり、チェックが一切入りません。

Material UIにv8が無いのはなぜですか?

MUI Xとメジャーバージョンを揃えるため、意図的に欠番にしたと公式が説明しています。アナウンスでは「…realigning: Material UI moves from v7 straight to v9 (there is no Material UI v8, like there is no v2), in step with MUI X v9」と述べられています。npmのレジストリ上も5系・6系・7系・9系のみが公開されており、8系は一つも存在しません。

TextFieldとAutocompleteはどちらを使うべきですか?

候補から選ばせたいならAutocomplete、自由入力ならTextFieldです。AutocompleteはTextFieldを置き換えるものではなく、renderInputを通じて内側にTextFieldを描く構成なので、ラベルやエラー表示などTextFieldの機能はそのまま使えます。選択肢が5個程度までなら、候補リストを開いて絞り込む操作が増えるぶん、Selectのほうが操作数は少なくなります。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.07 テックブログ ファインズの不正アクセスと153万件の予約者情報|予約システムを使う店舗の報告と通知の手順
  2. 2025.08.14 コラム トラクションとは?ビジネスでの意味・指標の目安とgain tractionの使い方
  3. 2026.10.15 テックブログ 銀のさら不正アクセス:最大650万件の対象確認と窓口、パスワード変更・退会の手順
  4. 2026.10.19 テックブログ コミックシーモア不正アクセスで会員情報流出:再開時期・パスワード再設定と運営側のログ点検
  5. 2026.10.16 テックブログ バーガーキング情報漏洩どうしたらいい?約1,118万件の対象確認・補償・退会とメールの真偽

RELATED POSTS 関連記事

目次