MUIとReact Hook Formの連携方法|インストールからControllerとsetFocusの落とし穴まで
MUIのコンポーネントをReact Hook Formにつなぐと、値の反映とエラーメッセージは動くのにフォーカス移動だけが無反応、という詰まり方をします。原因はMUIが入力要素をFormControlで包んだ構造にあり、Controllerから渡すrefをどの要素に着地させるかで挙動が分かれます。
この記事は、Material UI 9.3.1・React Hook Form 7.85.0・MUI X Date Pickers 9.11.0 を実際にインストールした環境で書いています。掲載しているコードはすべてそのままjsdomで実行し、29項目の検証がReact警告ゼロで通ったものです。インストールコマンド、Controllerの基本形、Select・チェックボックス・日付入力の接続、そしてsetFocusが黙って効かなくなる原因までを順に扱います。
まとめ
インストールはnpm install @mui/material @emotion/react @emotion/styledとnpm install react-hook-formの2本が基本形です。日付入力を使うなら@mui/x-date-pickersと日付ライブラリを足します。
Material UIにv8は存在しません。v7.0.0(2025年3月26日)の次がv9.0.0(2026年4月8日)で、MUI Xとメジャー番号を揃えるための欠番です。
MUIコンポーネントはControllerでラップし、renderの引数を({ field: { ref, ...field }, fieldState })と分解して、refはinputRefにだけ渡します。{...field}をそのまま展開するとrefがTextFieldのルート要素(div)に着地し、setFocusと送信時の自動フォーカスが無言で効かなくなります。値もエラー表示も正常に見えるため気付きにくい壊れ方です。
以降は、この結論に至った実行結果と、部品ごとの接続パターンを見ていきます。
MUI v9とReact Hook Formのインストール手順
3つのコマンドで揃うパッケージ構成
MUI本体・React Hook Form・日付入力の3つに分けて入れます。このうちMUI公式のInstallationページが案内する既定のコマンドは1本です。
npm install @mui/material @emotion/react @emotion/styled
@emotion/reactと@emotion/styledはMUIの既定のスタイリングエンジンです。9.3.1のpackage.jsonではpeerDependenciesMetaでoptional指定になっています。ただし公式が案内する差し替え先はstyled-components版(@mui/styled-engine-scとstyled-components)だけなので、そちらを選ぶ場合を除いて既定構成ではemotionの2つを入れます。
React Hook Formと日付入力は別コマンドで追加します。
npm install react-hook-form
npm install @mui/x-date-pickers date-fns
この記事の検証環境で実際に入ったバージョンは次のとおりです(2026年8月10日時点のlatest)。
| パッケージ | バージョン | 公開日 | 役割 |
|---|---|---|---|
| @mui/material | 9.3.1 | 2026-08-06 | UIコンポーネント |
| react-hook-form | 7.85.0 | 2026-08-08 | フォーム状態管理 |
| @mui/x-date-pickers | 9.11.0 | 2026-08-06 | DatePicker |
| @emotion/react | 11.14.0 | – | スタイリングエンジン |
| @hookform/resolvers | 5.7.1 | – | スキーマ連携 |
| zod | 4.4.3 | – | スキーマ定義 |
Reactは19.2.8で検証しました。reactとreact-domはMUIのpeer dependenciesなので、MUIより先に入っている必要があります。
Material UIにv8が存在しない理由
npmの@mui/materialには8系が1件も公開されていません。npm view @mui/material versionsを実行しても8で始まるバージョンは1件も出てこず、dist-tagsにもv8を指すタグがありません(あるのはlatest-v5・latest-v6・latest-v7とlatestです)。npm install @mui/material@8を指定するとインストールは失敗します。
MUIの発表記事は理由を明記しています。「Material UI moves from v7 straight to v9 (there is no Material UI v8, like there is no v2)」——先行していたMUI X v9とメジャー番号を揃えるための欠番です。MUI Xは2023年のv6でMaterial UIからバージョンを切り離しており、v9で再び合流しました。
紛らわしいのは、MUI X側にはv8が実在する点です(@mui/x-date-pickersのlatest-v8は8.29.0)。「MUI v8」で見つかる情報の大半はData GridやDate Pickersの話なので、Material UI本体のバージョンと読み替えないよう注意してください。併用するライブラリが独自に持つv8(React Router v8など)とも別物です。テーマやコンポーネント指定の書き方はメジャーで変わるため、参照する記事がv5時代かv9かは先に確認しておくと手戻りが減ります(MUIのLinkとReact Routerの連携でもcomponent指定の書き方が版によって変わります)。
React 18以下で必要なreact-isの固定
Material UI v9は内部でreact-is@19を使います。React 18以下のプロジェクトではreact-isのバージョンが食い違うとprop型チェックで実行時エラーが出るため、公式ドキュメントは使用中のReactと同じバージョンへ固定するよう案内しています。
npm install [email protected]
あわせてpackage.jsonでバージョンを固定します。
{
"overrides": {
"react-is": "^18.3.1"
}
}
React 19系ではこの手当ては不要です。v9へ上げた直後に原因不明のprop型エラーが出た場合は、まずここを疑ってください。
ControllerでMUIコンポーネントをつなぐ基本形
Controllerを使う最小構成
React Hook Formのregisterは非制御コンポーネントを前提にした設計です。MUIのTextFieldはFormControlとInputBaseを重ねた構造なので、値の出入りを明示的に受け渡すControllerのほうが扱いやすくなります。useFormやwatchなどReact Hook Form自体の基本APIはReact Hook Formの使い方とバリデーションで整理しています。
import { useForm, Controller } from 'react-hook-form';
import { TextField, Button } from '@mui/material';
export function ProfileForm({ onValid }) {
const { control, handleSubmit } = useForm({
defaultValues: { email: '' },
});
return (
<form onSubmit={handleSubmit(onValid)}>
<Controller
name="email"
control={control}
rules={{
required: 'メールアドレスを入力してください',
pattern: { value: /^[^@\s]+@[^@\s]+$/, message: 'メールアドレスの形式が正しくありません' },
}}
render={({ field: { ref, ...field }, fieldState }) => (
<TextField
{...field}
inputRef={ref}
label="メールアドレス"
error={Boolean(fieldState.error)}
helperText={fieldState.error?.message ?? ''}
/>
)}
/>
<Button type="submit" variant="contained">送信</Button>
</form>
);
}
要点はrenderの引数を({ field: { ref, ...field }, fieldState })と分解し、refをfieldから抜いてinputRefにだけ渡していることです。理由は次の章で扱います。
実行結果は、空のまま送信で「メールアドレスを入力してください」、fooを入れるとpattern側のメッセージ、[email protected]で{ email: "[email protected]" }が送信ハンドラへ渡る、という挙動でした。バリデーションNGの間は送信ハンドラが呼ばれないことも確認しています。
registerを直接渡す場合との使い分け
<TextField {...register('nick', { required: '必須です' })} />という書き方もMUIで動きます。この形も検証に含めており、値の反映({ nick: "taro" }が送信ハンドラへ到達)、必須チェック、setFocusによるフォーカス移動のすべてが動作しました。
動く理由はReact Hook Form側の実装にあります。registerが返すrefコールバックは、受け取った要素にvalueプロパティが無くかつquerySelectorAllを持つ場合、その配下からinput,select,textareaの先頭要素を取り出して登録対象にします。TextFieldのルートはdivですが、この掘り下げが働いて内部のinputまで届く仕組みです。
使い分けの基準はシンプルです。テキスト入力だけならregisterで短く書けます。一方Select・RadioGroup・Checkbox・DatePickerのように値が文字列以外だったりcheckedで状態を持つ部品はControllerが必要です。1つのフォームで両方の書き方が混ざると読み手の負担になるので、MUIを使うならControllerに統一しておくほうが保守は楽になります。
setFocusとエラー時の自動フォーカスが効かなくなる原因
fieldをそのまま展開したときの実測結果
解説記事でよく見かける次の書き方には落とし穴があります。
// フォーカスが効かなくなる書き方
render={({ field, fieldState }) => (
<TextField
{...field}
inputRef={field.ref}
error={Boolean(fieldState.error)}
helperText={fieldState.error?.message ?? ''}
/>
)}
fieldにはrefが含まれるため、{...field}の展開でTextFieldのrefpropにも同じコールバックが渡ります。MUIのTextFieldはrefをルートのFormControl(div)へ、inputRefを内部のinputへ渡す仕様なので、React Hook Formが記憶するフォーカス先が後から来たほうの要素で上書きされます。
3パターンを同一条件でjsdom上で実行し、明示的なsetFocus呼び出しと、送信時にReact Hook Formが行う自動フォーカスの両方を測りました。
| 書き方 | setFocus | 送信時の自動フォーカス |
|---|---|---|
| {…field} と inputRef={field.ref} | 効かない | 効かない |
| {…field} のみ(inputRefなし) | 効かない | 効かない |
| ref を分解して inputRef={ref} | 効く | 効く |
値の反映とエラーメッセージ表示は3パターンとも正常に動きます。フォーカスだけが例外も警告も出さずに死ぬため、入力項目の多いフォームでエラー箇所まで自動でスクロールしない、という形で後から気付くことになります。
refを分解する修正
// 抜粋(importとuseFormの宣言は前掲のとおり)
const { control, handleSubmit, setFocus } = useForm({
defaultValues: { email: '' },
});
const focusFirstError = (errors) => setFocus(Object.keys(errors)[0]);
return (
<form onSubmit={handleSubmit(onValid, focusFirstError)}>
<Controller
name="email"
control={control}
rules={{ required: '必須項目です' }}
render={({ field: { ref, ...field }, fieldState }) => (
<TextField
{...field}
inputRef={ref}
error={Boolean(fieldState.error)}
helperText={fieldState.error?.message ?? ''}
/>
)}
/>
<Button type="submit">送信</Button>
</form>
);
handleSubmitの第2引数はバリデーションNG時に呼ばれるコールバックで、errorsの先頭キーをsetFocusへ渡せば最初のエラー項目へ飛ばせます。なおuseFormのshouldFocusErrorは既定でtrueなので、refさえ正しく繋がっていればsetFocusを書かなくても送信時に自動でフォーカスが移ります。上の表で「送信時の自動フォーカス」が効いたのが、その既定動作です。
registerでは動くのにControllerで動かない理由
React Hook Formの配布ソースを読むと、2つのrefコールバックは別物です。
Controller側は、受け取った要素を{ focus, select, setCustomValidity, reportValidity }という4つのメソッドを持つラッパーに包んで内部の_f.refに格納します。このfocus()は「渡された要素のfocus()をそのまま呼ぶ」だけで、配下の要素を探しません。渡ってきたのがdivなら、divに対してfocus()が呼ばれ、フォーカス可能な要素ではないので何も起きません。
対してregister側は、前述のとおりquerySelectorAll('input,select,textarea')で内部のinputまで掘り下げます。この非対称性が「registerなら動くのにControllerだと動かない」の正体です。
Controllerを使う限り、refの分解は書き忘れると必ず壊れる定型処理です。フィールドごとにこの記述を繰り返すより、TextField用のラッパーコンポーネントを1つ作ってフォーム側ではrefを意識しない形にしておくことを勧めます。レビューで見落としても壊れない構造にするほうが確実です。
Select・ラジオ・チェックボックス・日付の接続パターン
TextFieldのselectによるプルダウン
// 抜粋(importとuseFormの宣言は前掲のとおり)
<Controller
name="plan"
control={control}
rules={{ required: 'プランを選択してください' }}
render={({ field: { ref, ...field }, fieldState }) => (
<TextField
{...field}
inputRef={ref}
select
label="プラン"
error={Boolean(fieldState.error)}
helperText={fieldState.error?.message ?? ''}
>
<MenuItem value="basic">Basic</MenuItem>
<MenuItem value="pro">Pro</MenuItem>
</TextField>
)}
/>
TextFieldにselectを付ける形は、Select・InputLabel・FormHelperTextの組み合わせをまとめて面倒を見てくれるため、エラー表示まで含めると記述量が減ります。Selectを単体で使う場合は、ラベルとヘルパーテキストを自分でFormControlの中に並べることになります。
実装で戸惑いやすいのは、TextFieldが描画する操作対象が<div role="combobox">になり、値を保持するinputは別に隠れて存在する点です。テストコードからプルダウンを開くときはクリックではなくmousedownを発火させる必要があります(MUIのSelectInputがonMouseDownで開く実装のため)。実際にこの方法でBasicとProの2件が開くことを確認しました。
RadioGroupとCheckboxの値の受け渡し
RadioGroupはvalueとonChangeをそのまま受け取るので{...field}で足ります。Checkboxはvalueではなくcheckedで状態を持つため、fieldからvalueを抜いてcheckedへ渡し替えます。
// 抜粋(importとuseFormの宣言は前掲のとおり)
<Controller
name="agree"
control={control}
rules={{ required: '利用規約への同意が必要です' }}
render={({ field: { value, ...field }, fieldState }) => (
<FormControl error={Boolean(fieldState.error)}>
<FormControlLabel
control={<Checkbox {...field} checked={value} />}
label="利用規約に同意する"
/>
<FormHelperText>{fieldState.error?.message ?? ''}</FormHelperText>
</FormControl>
)}
/>
チェックボックスはFormControlLabelがラベルを担当するため、エラーメッセージはFormControlにerrorを立てたうえでFormHelperTextに出します。defaultValuesにagree: falseを置いておくと、初回レンダリングで非制御から制御へ切り替わる警告を避けられます。
実行ではsize: 'm'でラジオのMが初期選択、agree: falseで未チェック、送信値が{"plan":"pro","size":"m","agree":true}になることを確認しました。
DatePickerとLocalizationProviderの組み合わせ
@mui/x-date-pickersはアダプタ経由で日付ライブラリを差し替える設計です。date-fns 4系ならAdapterDateFnsをLocalizationProviderに渡します。
// 抜粋(controlは前掲のuseFormから受け取る)
import { LocalizationProvider, DatePicker } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { ja } from 'date-fns/locale';
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={ja}>
<Controller
name="due"
control={control}
rules={{ required: '期日を入力してください' }}
render={({ field: { ref, ...field }, fieldState }) => (
<DatePicker
{...field}
inputRef={ref}
label="期日"
format="yyyy/MM/dd"
slotProps={{
textField: {
error: Boolean(fieldState.error),
helperText: fieldState.error?.message ?? '',
},
}}
/>
)}
/>
</LocalizationProvider>
エラー表示はslotProps.textFieldから内側のTextFieldへ渡します。DatePicker自体はerrorpropを受け取りません。
フォームが保持する値はDateオブジェクトのままです。2026/09/01と入力すると2026年9月1日のDateとして送信ハンドラに渡ることを確認しました。APIへ送る直前に文字列化する方針にしておくと、画面の表示フォーマットと送信形式を分けて管理できます。
入力候補を出すAutocompleteをつなぐ場合は、選択値の同一判定でつまずきやすい点が別にあります。詳細はMUI Autocompleteの実装とisOptionEqualToValueで整理しています。
useFieldArrayによる可変長フォームの実装
// 抜粋(importとuseFormの宣言は前掲のとおり)
const { control, handleSubmit } = useForm({
defaultValues: { tasks: [{ title: '' }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'tasks' });
return (
<form onSubmit={handleSubmit(onValid)}>
{fields.map((item, index) => (
<Controller
key={item.id}
name={`tasks.${index}.title`}
control={control}
rules={{ required: 'タスク名を入力してください' }}
render={({ field: { ref, ...field }, fieldState }) => (
<TextField
{...field}
inputRef={ref}
label={`タスク${index + 1}`}
error={Boolean(fieldState.error)}
helperText={fieldState.error?.message ?? ''}
/>
)}
/>
))}
<Button type="button" onClick={() => append({ title: '' })}>行を追加</Button>
<Button type="button" onClick={() => remove(fields.length - 1)}>末尾を削除</Button>
<Button type="submit">送信</Button>
</form>
);
keyに渡すのは配列のindexではなくitem.idです。useFieldArrayが行ごとに振る識別子で、indexをkeyにすると行を削除したときに入力値が1行ずれます。nameはテンプレートリテラルでtasks.${index}.titleと組み立て、Controllerの単位は行そのものではなく行の中の項目にします。
実行では初期1行から2回追加して3行、末尾削除で2行、送信値が{"tasks":[{"title":"設計"},{"title":"実装"}]}になること、各行に独立して必須チェックが効くことを確認しました。
rulesとzodResolverの使い分け
Controllerのrulesは項目単位のバリデーションです。パスワードの再入力一致や開始日と終了日の前後関係のように項目をまたぐ条件、あるいはサーバー側と同じ検証を共有したい場合は、スキーマバリデーションに寄せます。
// 抜粋(useFormのimportは前掲のとおり)
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
const schema = z.object({
email: z.email({ message: 'メールアドレスの形式が正しくありません' }),
age: z.coerce.number().int().min(18, { message: '18歳以上で入力してください' }),
});
const { control, handleSubmit } = useForm({
defaultValues: { email: '', age: '' },
resolver: zodResolver(schema),
});
@hookform/resolvers 5.7.1にzod 4.4.3のスキーマを渡す形です。z.coerce.number()を使うと、テキスト入力から来た文字列が送信時に数値へ変換されます。実行ではageに20と入力した結果が数値型の20として渡ることを確認しました。フォームの値は文字列のまま持ち、境界で型を揃える設計にできます。
判断の目安として、項目が数個で条件が独立しているならrulesのままで十分です。スキーマを別ファイルへ切り出す価値が出るのは、同じ検証をAPI側やテストでも使い回すときです。Zodの書き方そのものはReactでのZodを使ったフォームバリデーション、React Hook Form以外の選択肢との比較はTanStack FormとReact Hook Formの違いで扱っています。
react-hook-form-muiを採用すべき場面
react-hook-form-muiはControllerでラップ済みのコンポーネント群を配る第三者パッケージです。最新は9.0.1(2026年4月22日公開)で、TextFieldElement・SelectElement・CheckboxElement・RadioButtonGroup・AutocompleteElementなどにFormContainerを加えた15コンポーネントと、useFormError・useTransformの2フックを提供します。DatePicker系はreact-hook-form-mui/date-pickersという別エントリに分かれています。
peerDependenciesの必須は@mui/material ^9.0.0・@mui/system ^9.0.0・react-hook-form >=7.62.0・react >=18 <21で、@mui/icons-materialと@mui/x-date-pickersはpeerDependenciesMetaでoptional指定です。いずれにせよMUI v7以下のプロジェクトには入りません。
採用が向くのは、入力項目の多い管理画面で、フォームの見た目をパッケージの流儀に合わせてよい場合です。前章までに見たrefの分解やcheckedへの渡し替えといった定型を書かずに済みます。
逆に向かないのは、デザインシステムを自前で持ちslotPropsやテーマで細かく上書きしている場合です。ラッパー越しの上書きは調査コストが増えますし、peerがMUI v9に固定されている以上、本体のメジャー更新に追従するまでプロジェクト側が待たされます。自前でControllerを書く量はフィールドあたり十数行なので、パッケージを足す前にまず共通ラッパーを1つ作って足りるかを確かめるほうが、後の選択肢を狭めません。
よくある質問
MUIのインストールコマンドは何ですか?
既定構成ではnpm install @mui/material @emotion/react @emotion/styledです。reactとreact-domはpeer dependenciesなので先に入っている必要があります。アイコンを使うなら@mui/icons-material、日付入力なら@mui/x-date-pickersと日付ライブラリを追加してください。styled-componentsを使う構成に限り、emotionの代わりに@mui/styled-engine-scとstyled-componentsを入れます。
Material UI v8はどこで配布されていますか?
Material UI本体にv8は存在しません。npmの@mui/materialに8系は1件も公開されておらず、v7.0.0(2025年3月26日)の次がv9.0.0(2026年4月8日)です。MUI公式は、MUI Xとメジャー番号を揃えるために「v7から直接v9へ移る」と説明しています。検索で見つかる「MUI v8」の情報は、Data GridやDate PickersといったMUI X側のv8を指していることがほとんどです。
MUIのSelectBoxをReact Hook Formで扱うにはどうしますか?
TextFieldにselectを付け、Controllerでラップして{...field}を渡すのが最短です。エラー表示はerrorとhelperTextで完結します。Selectを単体で使うなら、FormControlの中にInputLabelとFormHelperTextを自分で並べることになります。どちらの場合も、初期値はuseFormのdefaultValuesに空文字を置いておくと未選択状態から始められます。
Controllerとregisterはどちらを使うべきですか?
テキスト入力だけならregisterを直接TextFieldに展開しても動きます。ただしCheckboxのようにcheckedで状態を持つ部品や、DatePickerのように値がDateオブジェクトの部品はControllerが必要です。1つのフォームで書き方が混在すると読みにくくなるため、MUIを使うならControllerに統一する方針を勧めます。
setFocusを呼んでもフォーカスが移らないのはなぜですか?
Controllerのrenderで{...field}をそのまま展開していることが原因である場合がほとんどです。fieldに含まれるrefがTextFieldのルート要素(div)に着地し、React Hook Formがdivに対してfocus()を呼ぶため何も起きません。({ field: { ref, ...field } })と分解し、refをinputRefにだけ渡すと解消します。値の反映とエラー表示は壊れた状態でも正常に動くため、フォーカスだけを個別に確認してください。