---
title: "MUIとReact Hook Formの連携｜インストールとref設定・v9とv8 betaの変更点"
url: "https://www.issoh.co.jp/tech/details/4113/"
published: 2024-11-11
updated: 2026-11-03
categories: ["React"]
publisher: "株式会社一創"
---

# MUIとReact Hook Formの連携｜インストールとref設定・v9とv8 betaの変更点

MUIのコンポーネントをReact Hook Formにつなぐと、値の反映とエラーメッセージは動くのに、フォーカス移動だけが反応しないことがあります。原因はMUIが入力要素を外側の要素で包んでいる構造にあり、`Controller`から渡す`ref`をどの要素に着地させるかで挙動が分かれます。Material UI v9ではCheckbox・Radio・Switchの`inputRef`が廃止されたため、v7時代の書き方のままだとこの問題が起きます。

この記事は、Material UI 9.5.0・React Hook Form 7.91.0・MUI X Date Pickers 9.17.0を実際にインストールした環境で書いています。正常系の実装例はjsdom上で実行し、React警告が出ないことと、値の送信・バリデーション・フォーカスの各挙動を確認しました。不具合を示す比較例では、本文に示す警告やフォーカス失敗も確認しています。React Hook Form 8.0.0-beta.5でも同じ検証を回し、v8で書き換えが必要になる箇所を拾っています。

## まとめ

インストールは`npm install @mui/material @emotion/react @emotion/styled`と`npm install react-hook-form`の2本が基本です。スキーマ検証を足すなら`@hookform/resolvers`は6.0.1以降を選びます（6.0.0は素のnpmでERESOLVEになる不具合がありました）。

MUIコンポーネントは`Controller`でラップし、`render`の引数を`({ field: { ref, ...field }, fieldState })`と分解して、`ref`を入力要素にだけ渡します。渡し先はTextFieldとDatePickerなら`inputRef`、Checkbox・Radio・Switchならv9で`slotProps={{ input: { ref } }}`に変わりました。`{...field}`をそのまま展開すると`ref`が外側の要素に着地し、`setFocus`と送信時の自動フォーカスが警告なしに効かなくなります。

React Hook Form v8はまだbetaで、`npm i react-hook-form@beta`で入るのは2025年12月の8.0.0-beta.0です。v8では`useFieldArray`の`fields`の識別子が`id`から`key`に変わるため、`key={item.id}`のままだとReactのkey警告が出ます。`@hookform/resolvers`と`react-hook-form-mui`はv8 betaをpeerの範囲に含めておらず、インストールの時点で止まります。

## MUI v9とReact Hook Formのインストール手順

### インストールコマンドと検証したバージョン

[MUI公式のInstallationページ](https://mui.com/material-ui/getting-started/installation/)が案内する既定のコマンドは次の1本です。`@emotion/react`と`@emotion/styled`は既定のスタイリングエンジンで、styled-components版へ差し替える構成を選ばない限り一緒に入れます。`react`と`react-dom`はpeer dependenciesなので、先に入っている必要があります。

```
npm install @mui/material @emotion/react @emotion/styled
```

React Hook Formと日付入力、スキーマ検証は別コマンドで追加します。

```
npm install react-hook-form
npm install @mui/x-date-pickers date-fns
npm install @hookform/resolvers zod
```

検証環境に入ったバージョンは次のとおりです（2026年11月3日時点のlatest）。

| パッケージ               | バージョン  | 公開日        |
| ------------------- | ------ | ---------- |
| @mui/material       | 9.5.0  | 2026-10-09 |
| react-hook-form     | 7.91.0 | 2026-10-23 |
| @mui/x-date-pickers | 9.17.0 | 2026-10-30 |
| @hookform/resolvers | 7.0.1  | 2026-11-01 |
| zod                 | 4.6.7  | 2026-10-12 |
| react / react-dom   | 19.3.1 | 2026-10-14 |
| date-fns            | 4.4.0  | 2026-05-29 |

MUI本体のテーマ設定やGridの使い方は[Material UIとは：v9系の導入からGrid・テーマ設定までコード付きで解説](/tech/details/17551/)にまとめています。この記事ではフォームの接続に絞ります。

### @hookform/resolversのバージョン選び

`@hookform/resolvers`は2026年10月だけでメジャーが2回上がりました。6.0.0（10月19日）は、任意のpeerに入っていた`@typeschema/main`経由で`effect`の版が衝突し、`npm install react-hook-form zod @hookform/resolvers`がERESOLVEで止まる状態でした。[6.0.1（10月27日）](https://github.com/react-hook-form/resolvers/releases/tag/v6.0.1)で`@typeschema/main`がpeerから外れ、この衝突は解消しています。

[7.0.0（10月31日）](https://github.com/react-hook-form/resolvers/releases/tag/v7.0.0)の破壊的変更はTypeBoxリゾルバだけで、peerが`@sinclair/typebox`から`typebox` 1.x へ移りました。Zodを使うフォームは7系へそのまま上げられます。6.0.0で固定しているlockfileがあれば、6.0.1以降に上げておくのが先です。

### Material UIのv8欠番とv10 alpha

npmの`@mui/material`には8系が1件も公開されていません。v7.0.0（2025年3月26日）の次がv9.0.0（2026年4月8日）で、[MUIの発表記事](https://mui.com/blog/introducing-mui-v9/)は「Material UI moves from v7 straight to v9 (there is no Material UI v8, like there is no v2)」と書いています。先行していたMUI X v9とメジャー番号を揃えるための欠番です。MUI X側にはv8が実在するので（`@mui/x-date-pickers`のlatest-v8は8.29.5）、「MUI v8」で見つかる情報の多くはData GridやDate Pickersの話です。

次のメジャーは、10.0.0-alpha.0が2026年10月12日に`next`タグで公開されています。破壊的変更として告知されたのはAutocompleteのポップアップの影（elevation）を1から8へ変える1件だけで、[v10の移行ガイド](https://next.mui.com/material-ui/migration/upgrade-to-v10/)は11月3日時点で導入文しかありません。本番のフォームはv9で組み、v10はalphaが外れてから評価するのが妥当です。

### React 18以下で必要なreact-isの固定

@mui/material 9.5.0は依存に`react-is@^19.3.0`を持ちます。React 18以下のプロジェクトでは`react-is`の版が食い違うため、公式Installationページは使用中のReactと同じ版へ固定するよう案内しています。

```
npm install react-is@18.3.1
```

```
{
  "overrides": {
    "react-is": "^18.3.1"
  }
}
```

React 19系ではこの手当ては不要です。v9へ上げた直後に原因の分からないprop型エラーが出たら、まずここを確認してください。

## ControllerでMUIのTextFieldをつなぐ基本形

### Controllerを使う最小構成

MUIのTextFieldは`FormControl`と`InputBase`を重ねた構造なので、値の出入りを明示的に受け渡す`Controller`で扱うのが基本です。`useForm`や`watch`などReact Hook Form自体の基本APIは[React Hook Formとは？使い方とv7の書き方・検証タイミング](/tech/details/2898/)で整理しています。

```
import { useForm, Controller, useFieldArray } from 'react-hook-form';
import {
  TextField, Button, MenuItem, RadioGroup, Radio, Checkbox,
  FormControlLabel, FormControl, FormHelperText, FormLabel, FormGroup,
} 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>
  );
}
```

空のまま送信すると「メールアドレスを入力してください」、`foo`ではpatternのメッセージが出て、どちらも送信ハンドラは呼ばれません。`taro@example.com`を入れると`{ email: "taro@example.com" }`が送信ハンドラに渡りました。

[React Hook Formの公式ドキュメント](https://react-hook-form.com/docs/usecontroller)の例は`onChange`などを1つずつ渡して`inputRef={field.ref}`を別に書く形で、上の書き方はそれを短くしたものです。

### registerを直接渡す場合との使い分け

`<TextField {...register('nick', { required: '必須です' })} />`もMUIで動きます。値の反映（`{ nick: "taro" }`が送信ハンドラへ到達）、必須チェック、`setFocus`のすべてが通りました。

ただしSelect・DatePickerなどの制御入力は`Controller`で接続すると値とイベントを明示的に扱えるため、MUIのフォームは`Controller`に統一したほうが読みやすくなります。

## setFocusとエラー時の自動フォーカスが効かなくなる原因

### fieldをそのまま展開したときの実測結果

解説記事でよく見かける次の書き方には落とし穴があります。

```
// フォーカスが効かなくなる書き方
render={({ field, fieldState }) => (
  <TextField
    {...field}
    inputRef={field.ref}
    error={Boolean(fieldState.error)}
    helperText={fieldState.error?.message ?? ''}
  />
)}
```

`field`には`ref`が含まれるため、`{...field}`の展開でTextFieldの`ref` propにも同じコールバックが渡ります。TextFieldは`ref`をルートのFormControl（div）へ、`inputRef`を内部のinputへ渡すので、React Hook Formが覚えるフォーカス先がdivで上書きされます。

4つの書き方を同じ条件で実行し、`setFocus`の呼び出しと、送信時にReact Hook Formが行う自動フォーカスを測りました。7.91.0と8.0.0-beta.5で結果は同じです。

| 書き方                             | setFocus | 送信時の自動フォーカス |
| ------------------------------- | -------- | ----------- |
| {…field} と inputRef={field.ref} | 効かない     | 効かない        |
| {…field} のみ                     | 効かない     | 効かない        |
| ref を分解して inputRef={ref}        | 効く       | 効く          |
| {…register(‘nick’)}             | 効く       | 効く          |

値の反映とエラーメッセージは4つとも正常です。フォーカスだけが警告なしに止まるので、「エラーの項目まで自動でスクロールしない」という形で後から気付きます。

### refを分解する修正

```
// 抜粋（importは前掲のとおり）
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時に呼ばれます。もっとも`shouldFocusError`は既定でtrueなので、`ref`が正しければ`setFocus`を書かなくても最初のエラー項目へフォーカスが移ります（表の「送信時の自動フォーカス」）。

テストで`setFocus`を確かめるときは、呼んだ直後に`document.activeElement`を見ても入力要素になっていません。`setFocus`は[setErrorとの競合を直した修正（#13169）](https://github.com/react-hook-form/react-hook-form/pull/13169)以降、`setTimeout`越しにフォーカスするためです。今回の検証でも、呼び出し直後はすべての書き方でfalse、次のタイマー後に判定して上の表の結果になりました。

### registerでは動くのにControllerで動かない理由

React Hook Formの配布ソースを読むと、2つの`ref`コールバックは別物です。`register`側は、受け取った要素に`value`が無ければ`querySelectorAll('input,select,textarea')`で配下の先頭の入力要素を取り出して登録します。TextFieldのルートがdivでも内部のinputまで届くのはこのためです。

`Controller`側は受け取った要素を掘り下げません。7.91.0では要素を`{ focus, select, setCustomValidity, reportValidity }`のラッパーに包んで保存し、`focus()`は渡された要素の`focus()`をそのまま呼びます。divに`focus()`を呼んでも何も起きません。

`{...field}`を展開する実装では、入力要素だけに`ref`を渡す処理を共通化できるため、TextField用のラッパーを1つ作り、フォーム側では`ref`を意識しない形にしておくのが確実です。

## Checkbox・Radio・Switchはv9でslotProps.input.refへ移行

Material UI v9では、Checkbox・Radio・Switchの`inputRef`と`inputProps`が削除されました。[v9の移行ガイド](https://mui.com/material-ui/migration/upgrade-to-v9/)は「`inputRef` — use `slotProps.input.ref` instead」と3部品それぞれに書いています。TextFieldとDatePickerの`inputRef`は残っているので、部品ごとに渡し先が違う点に注意が要ります。

9.5.0で`required`の項目を空のまま送信し、送信時の自動フォーカスが入力要素に移るかを測りました。

| 部品と書き方                        | 自動フォーカス | React警告 |
| ----------------------------- | ------- | ------- |
| Checkbox {…field}             | 効かない    | なし      |
| Checkbox inputRef={ref}       | 効かない    | あり      |
| Checkbox slotProps.input.ref  | 効く      | なし      |
| RadioGroup {…field}           | 効かない    | なし      |
| 先頭のRadioに slotProps.input.ref | 効く      | なし      |
| DatePicker inputRef={ref}     | 効く      | なし      |
| DatePicker {…field}           | 効かない    | なし      |

Checkboxに`inputRef`を渡すと、v9ではpropとして解釈されずにそのままDOMへ流れ、「React does not recognize the inputRef prop on a DOM element」という警告が出ます。警告が出るだけましで、`{...field}`で渡した場合は`ref`がルートのspanに着地して何も知らせずに止まります。v7からv9へ上げたフォームは、Checkbox・Radio・Switchの行を`inputRef`で検索して`slotProps`へ書き換えてください。

RadioGroupはグループ自体がdivなので、`ref`は先頭の`Radio`の`slotProps.input`へ渡します。Autocompleteをつなぐ場合の`slotProps`への移行は[MUI Autocompleteの実装と落とし穴｜isOptionEqualToValueとv9のslotProps移行](/tech/details/4153/)で扱っています。

## 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`をまとめて描画するので記述が短くなります。`defaultValues`に空文字を置けば未選択から始まります。操作対象は`<div role="combobox">`で、テストから開くときは`click`ではなく`mousedown`を発火させます（SelectInputが`onMouseDown`で開くため）。

### RadioGroupと単体Checkboxの値の受け渡し

```
// 抜粋（importとuseFormの宣言は前掲のとおり）
<Controller
  name="size"
  control={control}
  render={({ field: { ref, ...field } }) => (
    <RadioGroup {...field} row aria-label="サイズ">
      <FormControlLabel value="s" control={<Radio slotProps={{ input: { ref } }} />} label="S" />
      <FormControlLabel value="m" control={<Radio />} label="M" />
    </RadioGroup>
  )}
/>
<Controller
  name="agree"
  control={control}
  rules={{ required: '利用規約への同意が必要です' }}
  render={({ field: { value, ref, ...field }, fieldState }) => (
    <FormControl error={Boolean(fieldState.error)}>
      <FormControlLabel
        control={<Checkbox {...field} checked={value} slotProps={{ input: { ref } }} />}
        label="利用規約に同意する"
      />
      <FormHelperText>{fieldState.error?.message ?? ''}</FormHelperText>
    </FormControl>
  )}
/>
```

`RadioGroup`は`value`と`onChange`をそのまま受け取ります。`Checkbox`は`value`ではなく`checked`で状態を持つので、`field`から`value`を抜いて`checked`へ渡し替えます。`defaultValues`に`size: 'm'`と`agree: false`を置くと、Mが初期選択・未チェックの状態で始まり、送信値は`{"plan":"pro","size":"m","agree":true}`になりました。

### 複数選択のチェックボックス群

「使用言語を複数選ぶ」のように値を配列で持つ場合は、1つの`Controller`の中でCheckboxを並べ、`onChange`で配列を作り直して渡します。

```
// 抜粋（defaultValues: { langs: [] }）
const LANGS = ['TypeScript', 'Go', 'Python'];

<Controller
  name="langs"
  control={control}
  rules={{ validate: (v) => v.length > 0 || '1つ以上選択してください' }}
  render={({ field: { value, onChange, onBlur, ref }, fieldState }) => (
    <FormControl component="fieldset" error={Boolean(fieldState.error)}>
      <FormLabel component="legend">使用言語</FormLabel>
      <FormGroup>
        {LANGS.map((lang, i) => (
          <FormControlLabel
            key={lang}
            label={lang}
            control={
              <Checkbox
                checked={value.includes(lang)}
                onBlur={onBlur}
                slotProps={{ input: { ref: i === 0 ? ref : undefined } }}
                onChange={(e) =>
                  onChange(e.target.checked ? [...value, lang] : value.filter((v) => v !== lang))
                }
              />
            }
          />
        ))}
      </FormGroup>
      <FormHelperText>{fieldState.error?.message ?? ''}</FormHelperText>
    </FormControl>
  )}
/>
```

配列の必須チェックは`required`ではなく`validate`で長さを見ます。未選択で送信するとメッセージが出て先頭のチェックボックスにフォーカスが移り、TypeScriptを選択→Pythonを選択→TypeScriptを解除→TypeScriptを再選択した結果は`{"langs":["Python","TypeScript"]}`でした。配列は操作した順に並ぶので、表示順で送りたいなら送信前に`LANGS`の順へ並べ直します。

### FormControlがerrorとrequiredを子へ配る仕組み

MUIには`form`要素そのものを包むFormコンポーネントはありません（9.5.0のexportにあるのはFormControl・FormControlLabel・FormGroup・FormHelperText・FormLabel）。`FormControl`は1項目分の入れ物で、自分に付けた`error`や`required`をContext経由で子の`FormLabel`・`FormHelperText`へ配ります。

`<FormControl error required>`の中に`FormLabel`と`FormHelperText`を置くと、両方に`Mui-error`クラスが付き、ラベルには必須の「\*」が付きました。上のチェックボックスの例で`FormHelperText`に`error`を個別に書かなくてよいのはこの仕組みのためです。自作の部品で同じ状態を読みたいときは`useFormControl()`を使います。

### DatePickerと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}
    defaultValue={null}
    rules={{
      required: '期日を入力してください',
      validate: (value) =>
        (value instanceof Date && !Number.isNaN(value.getTime())) || '有効な日付を入力してください',
    }}
    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>
```

`required`だけでは`Invalid Date`が送信ハンドラまで通るため、`validate`で日付として有効かも確かめます。`setValue('due', new Date('invalid'))`の状態で送信すると「有効な日付を入力してください」で止まりました。エラー表示は`slotProps.textField`から内側のTextFieldへ渡します。フォームが持つ値はDateオブジェクトのままで、`2026/09/01`を入れると2026年9月1日のDateが送信ハンドラに渡りました。APIへ送る直前に文字列化しておくと、画面の表示形式と送信形式を分けて管理できます。

## 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ではなく、`useFieldArray`が行ごとに振る`item.id`を渡します。`name`はテンプレートリテラルで`tasks.${index}.title`と組み立て、`Controller`は行の中の項目ごとに置きます。初期1行から2回追加して3行、末尾削除で2行になり、各行に独立して必須チェックが効き、送信値は`{"tasks":[{"title":"設計"},{"title":"実装"}]}`でした。

## React Hook Form v8 betaでMUI連携に効く変更

### fieldsの識別子変更（idからkey）

[8.0.0-beta.1のリリースノート](https://github.com/react-hook-form/react-hook-form/releases/tag/v8.0.0-beta.1)は、`useFieldArray`の`id`を`key`へ改名し、`keyName`オプションを削除すると告知しています。8.0.0-beta.5で`fields[0]`のプロパティを出すと`['title', 'key']`で、7.91.0の`['title', 'id']`から変わっていました。

前章のコードをv8 betaでそのまま動かすと、`item.id`がundefinedになり、Reactの「Each child in a list should have a unique key prop」警告が出ます。`Controller`経由の制御入力なので、先頭行を消しても値のずれは起きませんでしたが、`key={item.key}`へ書き換えるまでは警告が消えません。`append`に渡すデータに`id`という項目があっても、v8では描画用の`key`とは別物として送信値に残ります。

beta.1の告知には「`setValue`では`useFieldArray`を直接更新しない、`replace`を使う」ともあります。ただしbeta.5で`setValue('tasks', [{ title: 'A' }])`を実行すると行は1行に更新され、7.91.0と同じ挙動でした。betaの間に挙動が動いているので、配列ごと入れ替える処理は両方で同じ結果になる`replace`で書いておくのが安全です。

### Controllerのref保存形式の変更

v8では`Controller`の`ref`が受け取った要素をそのまま保存します（beta.1の「pass input ref instead of partial」）。7.91.0のように4つのメソッドだけを持つラッパーではなくなりました。それでも配下を掘り下げない点は同じで、`{...field}`を展開するとフォーカス先がTextFieldのdivになり、`setFocus`も自動フォーカスも効きません。`ref`を分解して入力要素へ渡す修正は、v8でもそのまま必要です。

### v8 betaで止まるresolversとreact-hook-form-muiのインストール

npmの`beta`タグは2025年12月7日の8.0.0-beta.0を指したまま更新されていません。最新のbetaを試すには`npm install react-hook-form@8.0.0-beta.5`と版を明示します。

この状態で`@hookform/resolvers@7.0.1`を足すと、peerの`react-hook-form@"^7.55.0"`を満たせずERESOLVEになります。`react-hook-form-mui@9.0.1`もpeerが`>=7.62.0`で、npmの範囲指定はプレリリースを含まないため同じく止まります。`--legacy-peer-deps`を付ければインストールは通りますが、peerの範囲外なので動作の保証はありません。v8は試験用のブランチで評価し、本番は7.91系に置くのが現実的です。

## 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` 7.0.1にzod 4.6.7のスキーマを渡す形です。`age`に`17`なら「18歳以上で入力してください」で送信が止まり、`20`なら数値型の`20`が送信ハンドラに渡りました。`z.coerce.number()`がTextFieldから来る文字列を送信時に数値へ変換するので、フォームの値は文字列のまま持てます。

項目が数個で条件が独立しているなら`rules`で足ります。スキーマを別ファイルへ切り出す価値が出るのは、同じ検証をAPI側やテストでも使い回すときです。Zodの書き方は[ReactのZodフォームバリデーション実装｜zodResolverの導入とrefine・Zod 4の落とし穴](/tech/details/2371/)、React Hook Form以外の選択肢は[TanStack Formとは？使い方とReact Hook Formとの違い](/tech/details/6350/)で比べています。

## react-hook-form-muiを採用すべき場面

`react-hook-form-mui`は`Controller`でラップ済みのMUI部品を配る第三者パッケージです。最新は9.0.1（2026年4月22日）で、`TextFieldElement`・`SelectElement`・`CheckboxElement`・`RadioButtonGroup`・`AutocompleteElement`などを提供し、DatePicker系は`react-hook-form-mui/date-pickers`に分かれています。必須のpeerは`@mui/material ^9.0.0`・`@mui/system ^9.0.0`・`react-hook-form >=7.62.0`・`react >=18 <21`です。

向くのは、入力項目の多い管理画面やアンケート画面で、見た目をパッケージの流儀に合わせてよい場合です。前章までの`ref`の分解や`slotProps`への渡し替えを書かずに済みます。設問が数十項目に及ぶ入力画面を業務システムとして作る場合は、[アンケート管理システム開発](/service/system/enquetesystem/)のように画面設計から任せる選択肢もあります。

向かないのは、デザインシステムを自前で持ち、`slotProps`やテーマで細かく上書きしている場合です。ラッパー越しの上書きは調べにくく、前章のとおりRHF v8 betaもpeerの範囲外です。自前の`Controller`はフィールドあたり十数行なので、まず共通ラッパーで足りるかを確かめてください。

## よくある質問

### MUIのインストールコマンドは何ですか？

既定構成では`npm install @mui/material @emotion/react @emotion/styled`で、`react`と`react-dom`は先に入れておきます。アイコンは`@mui/icons-material`、日付入力は`@mui/x-date-pickers`と日付ライブラリを追加します。

### Material UI v8はどこで配布されていますか？

Material UI本体にv8はありません。v7.0.0の次がv9.0.0で、MUI Xとメジャー番号を揃えるための欠番です。「MUI v8」の情報はほとんどがMUI X側のv8を指しています。

### React Hook Form v8はもう使えますか？

2026年11月3日時点の正式版は7.91.0で、v8は8.0.0-beta.5（10月23日）までのbetaです。`beta`タグは古いbeta.0を指すので、試すなら版を明示して入れます。MUIと組み合わせる場合は、`fields`の`key`への改名と、`@hookform/resolvers`・`react-hook-form-mui`がpeerでインストールできない点が最初の障害になります。

### MUIのSelectBoxをReact Hook Formで扱うにはどうしますか？

`TextField`に`select`を付け、`Controller`でラップして`ref`を`inputRef`に、残りを`{...field}`で渡すのが最短です。エラー表示は`error`と`helperText`で完結します。`Select`を単体で使うなら、`FormControl`の中に`InputLabel`と`FormHelperText`を自分で並べます。

### setFocusを呼んでもフォーカスが移らないのはなぜですか？

多くは`{...field}`をそのまま展開し、`ref`がTextFieldのルートのdivに着地しているのが原因です。`({ field: { ref, ...field } })`と分解し、TextFieldなら`inputRef`、Checkbox・Radio・Switchなら`slotProps={{ input: { ref } }}`に渡します。テストでは`setFocus`が`setTimeout`越しに動く点も考慮します。

## 関連記事

- [React Hook Formとは？使い方とv7の書き方・検証タイミング【7.91対応】](/tech/details/2898/)
- [Material UIとは：v9系の導入からGrid・テーマ設定までコード付きで解説](/tech/details/17551/)
- [ReactのZodフォームバリデーション実装｜zodResolverの導入とrefine・Zod 4の落とし穴](/tech/details/2371/)
- [MUI Autocompleteの実装と落とし穴｜isOptionEqualToValueとv9のslotProps移行](/tech/details/4153/)
- [MUIのLinkの使い方｜React Router連携とテーマ一元化（MUI v9／React Router v8対応）](/tech/details/4299/)

---

出典: [MUIとReact Hook Formの連携｜インストールとref設定・v9とv8 betaの変更点](<https://www.issoh.co.jp/tech/details/4113/>)（株式会社一創）
